Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ describe('#1083 workflow run records reach the reports branch', () => {
expect(startRecord.username).toBe('alice');
expect(startRecord.sourceHash).toBe(sourceHash);
expect(startRecord.teamRevision).toBe(teamRevision);
expect(() => git(repo, 'cat-file', '-e', teamRevision)).not.toThrow();
expect(git(repo, 'show', `${teamRevision}:workflows/build.yaml`)).toBe(TWO_STEPS);
git(sandbox, 'clone', 'origin.git', 'fresh');
const fresh = path.join(sandbox, 'fresh');
expect(() => git(fresh, 'cat-file', '-e', teamRevision)).not.toThrow();
expect(git(fresh, 'show', `${teamRevision}:workflows/build.yaml`)).toBe(TWO_STEPS);

const done = await runLocalDriver(config, runId, work, {
tools: { default: 'claude' },
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/workflow-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,46 @@ describe('workflow cache', () => {
expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(localRevision);
});

it('keeps a verified revision when an unrelated unpushed commit moves HEAD', async () => {
const config = testConfig();
const repository = initializeTeamRepository(config);
writeWorkflow(config, 'build', VALID);
const pushed = repository.commit('add workflow');
repository.push();
await syncWorkflowDefinitions(config);

fs.writeFileSync(path.join(config.repo.localPath, 'README.md'), 'unrelated\n');
repository.commit('unrelated local commit');
await syncWorkflowDefinitions(config);

expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(pushed);
const started = await startRun(config, 'build');
if ('error' in started) throw new Error(started.error.message);
const run = await readRun(config, String(started.data.run));
if (typeof run === 'string') throw new Error(`Could not read run: ${run}`);
expect(run.teamRevision).toBe(pushed);
});

it('drops an unchanged workflow revision that the team remote no longer reaches', async () => {
const config = testConfig();
const repository = initializeTeamRepository(config);
writeWorkflow(config, 'build', VALID);
const pushed = repository.commit('add workflow');
repository.push();
await syncWorkflowDefinitions(config);
expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBe(pushed);

const tree = git(config.repo.localPath, 'write-tree');
const rewritten = git(config.repo.localPath, 'commit-tree', tree, '-m', 'rewrite team history');
git(config.repo.localPath, 'checkout', '-B', 'main', rewritten);
git(config.repo.localPath, 'push', '--force', '--set-upstream', 'origin', 'main');
fs.writeFileSync(path.join(config.repo.localPath, 'README.md'), 'unrelated\n');
repository.commit('unrelated local commit');
await syncWorkflowDefinitions(config);

expect((await loadCachedWorkflow(config, 'build'))?.teamRevision).toBeUndefined();
});

it('omits the team revision for an untracked workflow at a remote-reachable commit', async () => {
const config = testConfig();
const repository = initializeTeamRepository(config);
Expand Down Expand Up @@ -248,6 +288,26 @@ steps:
expect(mixedRun.teamRevision).toBeUndefined();
});

it('records the earlier team revision on a run of a kept workflow', async () => {
const config = testConfig();
const repository = initializeTeamRepository(config);
writeWorkflow(config, 'build', VALID);
const pushed = repository.commit('add workflow');
repository.push();
await syncWorkflowDefinitions(config);

writeWorkflow(config, 'build', 'schemaVersion: [\n');
repository.commit('break workflow');
repository.push();
expect((await syncWorkflowDefinitions(config)).kept).toEqual(['build']);

const started = await startRun(config, 'build');
if ('error' in started) throw new Error(started.error.message);
const run = await readRun(config, String(started.data.run));
if (typeof run === 'string') throw new Error(`Could not read run: ${run}`);
expect(run.teamRevision).toBe(pushed);
});

it('clears a kept workflow revision that the team remote no longer reaches', async () => {
const config = testConfig();
const repository = initializeTeamRepository(config);
Expand Down
17 changes: 9 additions & 8 deletions src/workflow/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { formatDiagnostic, parseWorkflow, promptFileDiagnostics, stepField } fro
import type { WorkflowFiles } from './parse-context.js';
import type { CachedWorkflow, WorkflowDefinition, WorkflowDiagnostic, WorkflowStep } from './types.js';
import { assertSafePath, assertWithinRoot } from '../utils/path-safety.js';
import { createGit } from '../utils/git.js';
import { createGit, getHeadCommit } from '../utils/git.js';

export interface SyncWorkflowOptions {
dryRun?: boolean;
Expand Down Expand Up @@ -136,11 +136,10 @@ async function isTeamRemoteRevision(root: string, revision: string): Promise<boo
/** The checked-out commit, only when the team remote can reach it. */
async function remotelyReachableRevision(root: string): Promise<ReachableRevision | undefined> {
try {
const git = createGit(root);
const revision = (await git.revparse(['HEAD'])).trim();
if (!await isTeamRemoteRevision(root, revision)) return undefined;
const revision = await getHeadCommit(root);
if (!revision || !await isTeamRemoteRevision(root, revision)) return undefined;
return {
repositoryRoot: await fs.realpath((await git.revparse(['--show-toplevel'])).trim()),
repositoryRoot: await fs.realpath((await createGit(root).revparse(['--show-toplevel'])).trim()),
revision,
};
} catch {
Expand Down Expand Up @@ -269,13 +268,15 @@ export async function syncWorkflowDefinitions(
reject(file, problems);
continue;
}
const previous = await readCache(config, file.definition.name);
const previous = kept.get(file.stem);
result.cached.push(file.definition.name);
if (options.dryRun || (previous?.sourceHash === file.sourceHash && previous.teamRevision === file.teamRevision)) continue;
// Equal bytes are still stored at the earlier entry's revision; the loop that filled `kept` dropped it if origin no longer reaches it.
const teamRevision = file.teamRevision ?? (previous?.sourceHash === file.sourceHash ? previous.teamRevision : undefined);
if (options.dryRun || (previous?.sourceHash === file.sourceHash && previous.teamRevision === teamRevision)) continue;
await writeJsonAtomic(cachePath(config, file.definition.name), {
relativePath: file.relativePath,
sourceHash: file.sourceHash,
...(file.teamRevision ? { teamRevision: file.teamRevision } : {}),
...(teamRevision ? { teamRevision } : {}),
content: file.content,
definition: file.definition,
validatedAt: new Date().toISOString(),
Expand Down
Loading