@open-agent-toolkit/cli 0.3.21 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/docs-tooling/add-docs-to-a-repo.md +3 -9
- package/assets/docs/reference/cli-reference.md +8 -3
- package/assets/docs/reference/configuration.md +3 -0
- package/assets/docs/reference/oat-directory-structure.md +1 -1
- package/assets/docs/reference/project-artifacts.md +141 -2
- package/assets/docs/reference/project-state-machine.md +2 -2
- package/assets/docs/workflows/advanced/autonomy.md +29 -12
- package/assets/docs/workflows/advanced/cursor-cloud.md +20 -6
- package/assets/docs/workflows/advanced/workflow-gates.md +9 -0
- package/assets/docs/workflows/projects/closeout/closeout-skills.md +24 -5
- package/assets/docs/workflows/projects/closeout/retro.md +6 -2
- package/assets/docs/workflows/projects/execution/implementation-execution.md +18 -4
- package/assets/docs/workflows/projects/execution/index.md +1 -1
- package/assets/docs/workflows/projects/execution/picking-up-projects.md +3 -2
- package/assets/docs/workflows/projects/execution/project-log.md +100 -11
- package/assets/docs/workflows/projects/lifecycle.md +3 -3
- package/assets/docs/workflows/projects/reviews/index.md +18 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-cursor-cloud-projects/SKILL.md +12 -8
- package/assets/skills/oat-cursor-cloud-projects/references/cursor-cloud-mechanics.md +63 -2
- package/assets/skills/oat-docs/SKILL.md +14 -28
- package/assets/skills/oat-docs-analyze/SKILL.md +4 -2
- package/assets/skills/oat-docs-analyze/references/legacy-scaffold-checks.md +16 -0
- package/assets/skills/oat-docs-apply/SKILL.md +2 -2
- package/assets/skills/oat-docs-authoring/SKILL.md +2 -2
- package/assets/skills/oat-docs-authoring/references/lifecycle-boundaries.md +1 -2
- package/assets/skills/oat-docs-authoring/references/mkdocs-to-oat-fumadocs-refactor-guide.md +73 -0
- package/assets/skills/oat-docs-bootstrap/SKILL.md +44 -310
- package/assets/skills/oat-project-autonomous/SKILL.md +88 -26
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +1 -1
- package/assets/skills/oat-project-brief/SKILL.md +1 -1
- package/assets/skills/oat-project-brief/references/brief-contract.md +33 -27
- package/assets/skills/oat-project-brief/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-complete/SKILL.md +142 -25
- package/assets/skills/oat-project-complete-auto/SKILL.md +4 -4
- package/assets/skills/oat-project-dispatch-subagents/SKILL.md +10 -5
- package/assets/skills/oat-project-document/SKILL.md +1 -1
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-goal/SKILL.md +63 -15
- package/assets/skills/oat-project-goal/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/SKILL.md +6 -6
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +28 -7
- package/assets/skills/oat-project-implement/references/dispatch-and-dry-run.md +10 -5
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-implement/references/phase-execution.md +23 -9
- package/assets/skills/oat-project-implement/references/plan-and-resume.md +4 -4
- package/assets/skills/oat-project-lite/SKILL.md +1 -1
- package/assets/skills/oat-project-lite/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-pr-final/SKILL.md +6 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-pr-progress/SKILL.md +6 -1
- package/assets/skills/oat-project-quick-start/SKILL.md +1 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +1 -1
- package/assets/skills/oat-project-reconcile/SKILL.md +13 -19
- package/assets/skills/oat-project-retro/SKILL.md +5 -2
- package/assets/skills/oat-project-retro/references/evidence-and-lanes.md +9 -3
- package/assets/skills/oat-project-review-provide/SKILL.md +28 -3
- package/assets/skills/oat-project-review-receive/SKILL.md +32 -18
- package/assets/skills/oat-project-review-receive-remote/SKILL.md +10 -6
- package/assets/skills/oat-project-revise/SKILL.md +9 -2
- package/assets/skills/oat-project-summary/SKILL.md +166 -37
- package/assets/skills/oat-worktree-bootstrap-auto/SKILL.md +3 -3
- package/assets/templates/docs-app-fuma/docs/contributing.md +6 -0
- package/assets/templates/docs-app-fuma/docs/getting-started.md +1 -1
- package/assets/templates/docs-app-fuma/next.config.js +5 -1
- package/assets/templates/implementation-brief.md +5 -36
- package/assets/templates/implementation.md +11 -81
- package/assets/templates/project-log.md +2 -31
- package/assets/templates/project-retro.md +4 -1
- package/assets/templates/summary.md +13 -5
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +44 -1
- package/dist/commands/docs/analyze.js +1 -1
- package/dist/commands/docs/apply.js +1 -1
- package/dist/commands/docs/init/scaffold.d.ts.map +1 -1
- package/dist/commands/docs/init/scaffold.js +35 -2
- package/dist/commands/project/archive/archive-utils.d.ts +205 -8
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +708 -44
- package/dist/commands/project/archive/index.d.ts.map +1 -1
- package/dist/commands/project/archive/index.js +1 -0
- package/dist/commands/project/archive/push-runner.d.ts +9 -1
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +56 -18
- package/dist/commands/project/archive/sync-runner.d.ts +28 -0
- package/dist/commands/project/archive/sync-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/sync-runner.js +44 -22
- package/dist/commands/project/complete-state/index.d.ts.map +1 -1
- package/dist/commands/project/complete-state/index.js +45 -0
- package/dist/commands/project/dispatch/index.d.ts.map +1 -1
- package/dist/commands/project/dispatch/index.js +24 -2
- package/dist/commands/project/dispatch/record.d.ts +54 -1
- package/dist/commands/project/dispatch/record.d.ts.map +1 -1
- package/dist/commands/project/dispatch/record.js +229 -1
- package/dist/commands/project/index.d.ts.map +1 -1
- package/dist/commands/project/index.js +2 -0
- package/dist/commands/project/journal-check/evaluator.d.ts +17 -0
- package/dist/commands/project/journal-check/evaluator.d.ts.map +1 -0
- package/dist/commands/project/journal-check/evaluator.js +140 -0
- package/dist/commands/project/journal-check/index.d.ts +34 -0
- package/dist/commands/project/journal-check/index.d.ts.map +1 -0
- package/dist/commands/project/journal-check/index.js +163 -0
- package/dist/commands/project/journal-check/rules.d.ts +68 -0
- package/dist/commands/project/journal-check/rules.d.ts.map +1 -0
- package/dist/commands/project/journal-check/rules.js +994 -0
- package/dist/commands/project/journal-check/types.d.ts +63 -0
- package/dist/commands/project/journal-check/types.d.ts.map +1 -0
- package/dist/commands/project/journal-check/types.js +26 -0
- package/dist/commands/project/log/append.d.ts +6 -0
- package/dist/commands/project/log/append.d.ts.map +1 -1
- package/dist/commands/project/log/append.js +114 -6
- package/dist/commands/project/log/check.d.ts +23 -1
- package/dist/commands/project/log/check.d.ts.map +1 -1
- package/dist/commands/project/log/check.js +20 -3
- package/dist/commands/project/log/grammar.d.ts +37 -0
- package/dist/commands/project/log/grammar.d.ts.map +1 -1
- package/dist/commands/project/log/grammar.js +72 -0
- package/dist/commands/project/log/rollup.d.ts +87 -1
- package/dist/commands/project/log/rollup.d.ts.map +1 -1
- package/dist/commands/project/log/rollup.js +257 -5
- package/dist/config/oat-config.d.ts +14 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +17 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +5 -1
- package/dist/providers/identity/generic-dispatch-record.d.ts +33 -0
- package/dist/providers/identity/generic-dispatch-record.d.ts.map +1 -1
- package/dist/providers/identity/generic-dispatch-record.js +60 -1
- package/package.json +2 -2
|
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { link, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, isAbsolute, join, posix as path, relative, resolve, sep, } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
+
import { PROJECT_LOG_LOCATOR_PREFIX } from '../../project/log/rollup.js';
|
|
6
7
|
import { defaultGitRunner } from '../../project/sync/git.js';
|
|
7
8
|
import { readSyncedRecord, writeSyncedRecord, } from '../../project/sync/record.js';
|
|
8
9
|
import { buildSyncTarget, commitRecordChange, preflightSyncedCheckout, removeSyncedCheckout, retireSyncedRef, } from '../../project/sync/ref-sync.js';
|
|
@@ -15,10 +16,37 @@ import { loadExplainerPackageCoverage } from './explainer-package-coverage.js';
|
|
|
15
16
|
const execFileAsync = promisify(execFileCallback);
|
|
16
17
|
export const ARCHIVE_SNAPSHOT_METADATA_FILENAME = '.oat-archive-source.json';
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* Top-level project entries an archive never retains: review and PR evidence
|
|
20
|
+
* are process artifacts, not project deliverables (D2), and a synced
|
|
21
|
+
* checkout's `.git` link is not project content. This one list defines the
|
|
22
|
+
* retained archive: the local copy, the retained-artifact manifest, the S3
|
|
23
|
+
* upload and the S3 hydration all read it, so an archive restored from S3
|
|
24
|
+
* hashes equal to the archive that was uploaded.
|
|
20
25
|
*/
|
|
21
|
-
export const
|
|
26
|
+
export const ARCHIVE_RETENTION_EXCLUSIONS = [
|
|
27
|
+
'reviews',
|
|
28
|
+
'pr',
|
|
29
|
+
'.git',
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* `aws s3 sync --exclude` patterns for the upload and the hydration, derived
|
|
33
|
+
* from `ARCHIVE_RETENTION_EXCLUSIONS` so S3 never holds or restores a
|
|
34
|
+
* top-level entry the manifest does not retain.
|
|
35
|
+
*/
|
|
36
|
+
export const S3_ARCHIVE_SYNC_EXCLUDES = ARCHIVE_RETENTION_EXCLUSIONS.map((entry) => `${entry}/*`);
|
|
37
|
+
/**
|
|
38
|
+
* Top-level entries the archive itself owns. They are derived at archive time,
|
|
39
|
+
* so the manifest that identifies the retained project content excludes them.
|
|
40
|
+
*/
|
|
41
|
+
export const ARCHIVE_OWNED_ENTRIES = [
|
|
42
|
+
ARCHIVE_SNAPSHOT_METADATA_FILENAME,
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* The prefix an exported summary's finalized source locator carries:
|
|
46
|
+
* `oat-archive:<snapshot>#<entry-id>`, where `<snapshot>` is the persisted
|
|
47
|
+
* dated export identity (also the summary export file stem and the S3 key).
|
|
48
|
+
*/
|
|
49
|
+
export const ARCHIVE_LOCATOR_PREFIX = 'oat-archive:';
|
|
22
50
|
export function assertExactArchiveProjectRoot(options) {
|
|
23
51
|
const projectScope = resolveProjectScope(options.projectPath, resolveScopeRoot(options.repoRoot, options.projectsRoot, 'shared'), options.repoRoot);
|
|
24
52
|
if (!projectScope) {
|
|
@@ -103,29 +131,54 @@ function normalizeArchiveDateStamp(timestamp) {
|
|
|
103
131
|
export function buildArchiveSnapshotName(projectName, timestamp) {
|
|
104
132
|
return `${normalizeArchiveDateStamp(timestamp)}-${projectName}`;
|
|
105
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The one archive naming contract, shared by the writer and every reader:
|
|
136
|
+
* local name `<slug>[-gN][-<scope>]` and snapshot (export identity)
|
|
137
|
+
* `<YYYYMMDD>-<local-name>`. `-gN` (N >= 2) names a successor generation;
|
|
138
|
+
* `-<scope>` qualifies an identity whose unqualified name another scope's
|
|
139
|
+
* archive already holds.
|
|
140
|
+
*/
|
|
141
|
+
export function formatArchiveLocalName(projectName, generation, scope) {
|
|
142
|
+
return `${projectName}${generation > 1 ? `-g${generation}` : ''}${scope ? `-${scope}` : ''}`;
|
|
143
|
+
}
|
|
144
|
+
const ARCHIVE_LOCAL_NAME_PATTERN = /^(.+?)(?:-g([2-9]|[1-9]\d+))?(?:-(shared|local|synced))?$/;
|
|
145
|
+
/**
|
|
146
|
+
* Parses an S3 snapshot name under the naming contract of
|
|
147
|
+
* `formatArchiveLocalName`. The local name is the archive directory the
|
|
148
|
+
* snapshot hydrates to, so generation 2 of `<project>` restores to
|
|
149
|
+
* `<project>-g2` (or `<project>-g2-<scope>`), never over generation 1. An
|
|
150
|
+
* undated name is its own local, family and project name. A project whose own
|
|
151
|
+
* slug ends in `-gN` or `-<scope>` reads as that generation or qualifier: the
|
|
152
|
+
* residual ambiguity the by-name sync precedence documents.
|
|
153
|
+
*/
|
|
106
154
|
export function parseArchiveSnapshotName(snapshotName) {
|
|
107
155
|
const trimmedSnapshot = snapshotName.replace(/\/+$/, '');
|
|
108
156
|
const match = trimmedSnapshot.match(/^(\d{8})-(.+)$/);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
snapshotName: trimmedSnapshot,
|
|
113
|
-
dateStamp: null,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
const dateStamp = match[1];
|
|
117
|
-
const projectName = match[2];
|
|
118
|
-
if (!dateStamp || !projectName) {
|
|
157
|
+
const dateStamp = match?.[1];
|
|
158
|
+
const localName = match?.[2];
|
|
159
|
+
if (!dateStamp || !localName) {
|
|
119
160
|
return {
|
|
120
161
|
projectName: trimmedSnapshot,
|
|
162
|
+
localName: trimmedSnapshot,
|
|
163
|
+
familyName: trimmedSnapshot,
|
|
164
|
+
scope: null,
|
|
121
165
|
snapshotName: trimmedSnapshot,
|
|
122
166
|
dateStamp: null,
|
|
167
|
+
generation: 1,
|
|
123
168
|
};
|
|
124
169
|
}
|
|
170
|
+
const parts = localName.match(ARCHIVE_LOCAL_NAME_PATTERN);
|
|
171
|
+
const projectName = parts?.[1] ?? localName;
|
|
172
|
+
const generation = parts?.[2] ? Number(parts[2]) : 1;
|
|
173
|
+
const scope = parts?.[3] ?? null;
|
|
125
174
|
return {
|
|
126
175
|
projectName,
|
|
176
|
+
localName,
|
|
177
|
+
familyName: formatArchiveLocalName(projectName, 1, scope),
|
|
178
|
+
scope,
|
|
127
179
|
snapshotName: trimmedSnapshot,
|
|
128
180
|
dateStamp,
|
|
181
|
+
generation,
|
|
129
182
|
};
|
|
130
183
|
}
|
|
131
184
|
export function resolveLocalArchiveProjectPath(projectsRoot, projectName) {
|
|
@@ -274,7 +327,7 @@ async function resolvePersistedArchivePath(archiveBasePath, projectName, snapsho
|
|
|
274
327
|
}
|
|
275
328
|
}
|
|
276
329
|
if (matches.length > 1) {
|
|
277
|
-
throw new
|
|
330
|
+
throw new ArchiveIdentityError('archive-retry-ambiguous', `Persisted archive snapshot \`${snapshotName}\` resolves to multiple local archives; refusing an ambiguous retry.`);
|
|
278
331
|
}
|
|
279
332
|
return matches[0] ?? join(archiveRoot, snapshotName);
|
|
280
333
|
}
|
|
@@ -337,6 +390,476 @@ export function assertDurableArchiveProjectTarget(target) {
|
|
|
337
390
|
throw new CliError(target.localOnlyWarning);
|
|
338
391
|
}
|
|
339
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Hashes the canonical retained-artifact manifest of a project or archive
|
|
395
|
+
* directory: every regular file below `root`, except the top-level
|
|
396
|
+
* `ARCHIVE_RETENTION_EXCLUSIONS` and `ARCHIVE_OWNED_ENTRIES`, as sorted
|
|
397
|
+
* `<sha256> <posix path>` lines. A source project and the archive copied from
|
|
398
|
+
* it therefore hash equal, so an identical retry is recognized from either
|
|
399
|
+
* side. Symlinks and empty directories are not retained by the copy and are
|
|
400
|
+
* not hashed. Read-only.
|
|
401
|
+
*/
|
|
402
|
+
export async function computeArchiveManifestHash(root) {
|
|
403
|
+
const excluded = new Set([
|
|
404
|
+
...ARCHIVE_RETENTION_EXCLUSIONS,
|
|
405
|
+
...ARCHIVE_OWNED_ENTRIES,
|
|
406
|
+
]);
|
|
407
|
+
const lines = [];
|
|
408
|
+
const walk = async (directory, prefix) => {
|
|
409
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
410
|
+
for (const entry of entries) {
|
|
411
|
+
if (prefix === '' && excluded.has(entry.name))
|
|
412
|
+
continue;
|
|
413
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
414
|
+
const absolutePath = join(directory, entry.name);
|
|
415
|
+
if (entry.isDirectory()) {
|
|
416
|
+
await walk(absolutePath, relativePath);
|
|
417
|
+
}
|
|
418
|
+
else if (entry.isFile()) {
|
|
419
|
+
lines.push(`${sha256(await readFile(absolutePath))} ${relativePath}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
await walk(root, '');
|
|
424
|
+
lines.sort();
|
|
425
|
+
return sha256(lines.join('\n'));
|
|
426
|
+
}
|
|
427
|
+
function readCandidateMetadata(value) {
|
|
428
|
+
if (!isRecord(value) ||
|
|
429
|
+
typeof value.projectName !== 'string' ||
|
|
430
|
+
typeof value.snapshotName !== 'string' ||
|
|
431
|
+
(value.scope !== 'shared' &&
|
|
432
|
+
value.scope !== 'local' &&
|
|
433
|
+
value.scope !== 'synced')) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
projectName: value.projectName,
|
|
438
|
+
snapshotName: value.snapshotName,
|
|
439
|
+
scope: value.scope,
|
|
440
|
+
...(typeof value.sourceRefSha === 'string'
|
|
441
|
+
? { sourceRefSha: value.sourceRefSha }
|
|
442
|
+
: {}),
|
|
443
|
+
...(isSha256(value.manifestHash)
|
|
444
|
+
? { manifestHash: value.manifestHash }
|
|
445
|
+
: {}),
|
|
446
|
+
...(typeof value.generation === 'number' &&
|
|
447
|
+
Number.isInteger(value.generation) &&
|
|
448
|
+
value.generation > 0
|
|
449
|
+
? { generation: value.generation }
|
|
450
|
+
: {}),
|
|
451
|
+
...(typeof value.predecessor === 'string'
|
|
452
|
+
? { predecessor: value.predecessor }
|
|
453
|
+
: {}),
|
|
454
|
+
...(isSha256(value.generationRequest)
|
|
455
|
+
? { generationRequest: value.generationRequest }
|
|
456
|
+
: {}),
|
|
457
|
+
...(typeof value.generationReason === 'string'
|
|
458
|
+
? { generationReason: value.generationReason }
|
|
459
|
+
: {}),
|
|
460
|
+
...(value.allocation === 'pending'
|
|
461
|
+
? { allocation: 'pending' }
|
|
462
|
+
: {}),
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function generationOf(candidate) {
|
|
466
|
+
return candidate.metadata?.generation ?? 1;
|
|
467
|
+
}
|
|
468
|
+
function identityFromMetadata(metadata) {
|
|
469
|
+
return {
|
|
470
|
+
predecessor: metadata?.predecessor ?? null,
|
|
471
|
+
generationRequest: metadata?.generationRequest ?? null,
|
|
472
|
+
generationReason: metadata?.generationReason ?? null,
|
|
473
|
+
allocationPending: metadata?.allocation === 'pending',
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* The warning both apply and dry-run report when `--new-generation` resolved to
|
|
478
|
+
* an existing generation: the retained artifacts are identical to it, so no
|
|
479
|
+
* generation is allocated and the reason is not recorded.
|
|
480
|
+
*/
|
|
481
|
+
export function newGenerationReplayWarning(identity, newGenerationReason) {
|
|
482
|
+
if (newGenerationReason == null || identity.disposition !== 'retry') {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const resolved = `\`--new-generation\` resolved to existing generation ${identity.generation} (\`${identity.snapshotName}\`): retained artifacts are identical to that archive`;
|
|
486
|
+
// Replaying an explicit generation: its reason is already persisted.
|
|
487
|
+
return identity.generationReason
|
|
488
|
+
? `${resolved}, allocated for reason \`${identity.generationReason}\`; no new generation was allocated.`
|
|
489
|
+
: `${resolved}; the reason was not recorded.`;
|
|
490
|
+
}
|
|
491
|
+
/** Validates `--new-generation <reason>`: one non-empty line. */
|
|
492
|
+
export function validateNewGenerationReason(reason) {
|
|
493
|
+
const trimmed = reason.trim();
|
|
494
|
+
if (trimmed === '' || /[\r\n]/.test(trimmed) || trimmed.length > 200) {
|
|
495
|
+
throw new ArchiveIdentityError('archive-generation-reason', '--new-generation requires a non-empty, single-line reason of at most 200 characters.', 2);
|
|
496
|
+
}
|
|
497
|
+
return trimmed;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Every local archive that could hold this project: each directory whose
|
|
501
|
+
* metadata names the project (in any scope), plus any non-empty directory at
|
|
502
|
+
* the project's base archive path whose metadata does not.
|
|
503
|
+
*/
|
|
504
|
+
async function readArchiveIdentityCandidates(archiveBasePath, projectName) {
|
|
505
|
+
const archiveRoot = dirname(archiveBasePath);
|
|
506
|
+
let entries;
|
|
507
|
+
try {
|
|
508
|
+
entries = await readdir(archiveRoot, { withFileTypes: true });
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
const candidates = [];
|
|
514
|
+
for (const entry of entries
|
|
515
|
+
.filter((candidate) => candidate.isDirectory())
|
|
516
|
+
.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
517
|
+
const archivePath = join(archiveRoot, entry.name);
|
|
518
|
+
let metadata = null;
|
|
519
|
+
try {
|
|
520
|
+
metadata = readCandidateMetadata(JSON.parse(await readFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
metadata = null;
|
|
524
|
+
}
|
|
525
|
+
if (metadata?.projectName === projectName) {
|
|
526
|
+
candidates.push({ archivePath, metadata });
|
|
527
|
+
}
|
|
528
|
+
else if (archivePath === archiveBasePath &&
|
|
529
|
+
(await readdir(archivePath)).length > 0) {
|
|
530
|
+
// An empty directory holds no archive; anything else at the base path
|
|
531
|
+
// that does not identify itself as this project cannot be verified.
|
|
532
|
+
candidates.push({ archivePath, metadata: null });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return candidates;
|
|
536
|
+
}
|
|
537
|
+
/** Stable reason codes of the archive identity refusals. */
|
|
538
|
+
export const ARCHIVE_IDENTITY_REFUSAL_CODES = [
|
|
539
|
+
'archive-retry-ambiguous',
|
|
540
|
+
'archive-retry-conflict',
|
|
541
|
+
'archive-occupant-unverifiable',
|
|
542
|
+
'archive-generation-synced',
|
|
543
|
+
'archive-generation-reason',
|
|
544
|
+
'archive-generation-source-absent',
|
|
545
|
+
'archive-generation-no-predecessor',
|
|
546
|
+
'archive-generation-ambiguous-predecessor',
|
|
547
|
+
'archive-generation-pending',
|
|
548
|
+
'archive-generation-path-occupied',
|
|
549
|
+
'archive-synced-recordless-unbound',
|
|
550
|
+
'archive-synced-recordless-ambiguous',
|
|
551
|
+
'archive-retry-candidate-damaged',
|
|
552
|
+
];
|
|
553
|
+
/**
|
|
554
|
+
* An archive identity refusal: fails closed before any write, with a stable
|
|
555
|
+
* reason code that prefixes the message and that `--json` reports as `code`.
|
|
556
|
+
*/
|
|
557
|
+
export class ArchiveIdentityError extends CliError {
|
|
558
|
+
code;
|
|
559
|
+
constructor(code, message, exitCode = 1) {
|
|
560
|
+
super(`${code}: ${message}`, exitCode);
|
|
561
|
+
this.code = code;
|
|
562
|
+
this.name = 'ArchiveIdentityError';
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Recovery for a directory that does not identify itself: most often a partial
|
|
567
|
+
* copy an interrupted archive left behind before pending allocations were
|
|
568
|
+
* written (current archives write pending metadata before the copy, so their
|
|
569
|
+
* interrupted runs resume instead).
|
|
570
|
+
*/
|
|
571
|
+
function occupantGuidance(projectName) {
|
|
572
|
+
return `If this directory is a partial copy left by an interrupted archive, inspect and remove it, then retry; otherwise record reopened work in a follow-up project (for example \`oat project new ${projectName}-followup\`).`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Rehashes a retry candidate's retained bytes before the retry is acted on.
|
|
576
|
+
* The current manifest must equal the hash recorded at archive time and, when
|
|
577
|
+
* the source is present, the source's manifest; otherwise the archive was
|
|
578
|
+
* damaged after it was written, and adopting it would let the apply remove the
|
|
579
|
+
* only intact copy. A pending generation allocation holds no copy yet and is
|
|
580
|
+
* re-copied by the apply, so it is not rehashed. Read-only.
|
|
581
|
+
*/
|
|
582
|
+
async function assertRetryCandidateIntact(projectName, archivePath, metadata, sourceManifestHash) {
|
|
583
|
+
if (metadata?.allocation === 'pending')
|
|
584
|
+
return;
|
|
585
|
+
const actual = await computeArchiveManifestHash(archivePath);
|
|
586
|
+
const recordedMismatch = metadata?.manifestHash !== undefined && metadata.manifestHash !== actual;
|
|
587
|
+
const sourceMismatch = sourceManifestHash !== null && sourceManifestHash !== actual;
|
|
588
|
+
if (recordedMismatch || sourceMismatch) {
|
|
589
|
+
throw new ArchiveIdentityError('archive-retry-candidate-damaged', `Archive \`${archivePath}\` of \`${projectName}\` no longer matches ${recordedMismatch
|
|
590
|
+
? 'the manifest recorded when it was written'
|
|
591
|
+
: 'the project it would replace'} (current ${actual}); refusing to adopt it as a retry before any export, copy, removal or S3 mutation. Restore the archive from its S3 snapshot (\`oat repo archive sync ${metadata?.snapshotName ?? projectName} --force\`) or from backup, then retry; the project directory is left in place.`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function followUpGuidance(projectName) {
|
|
595
|
+
return `Record reopened work in a follow-up project (for example \`oat project new ${projectName}-followup\`) and archive that project instead.`;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The one read-only evaluator of archive retry identity, shared by the archive
|
|
599
|
+
* apply and its dry-run. Persisted identity is the project, its originating
|
|
600
|
+
* scope and the snapshot/generation recorded in `.oat-archive-source.json`,
|
|
601
|
+
* bound to the canonical retained-artifact manifest (and, for synced projects,
|
|
602
|
+
* the source ref SHA):
|
|
603
|
+
*
|
|
604
|
+
* - a persisted synced snapshot keeps its recorded identity;
|
|
605
|
+
* - a present source whose manifest matches exactly one same-scope archive is
|
|
606
|
+
* an identical retry and reuses that archive's snapshot, dated export
|
|
607
|
+
* identity and generation, across dates;
|
|
608
|
+
* - an absent source resumes the single same-scope archive;
|
|
609
|
+
* - any other same-scope archive, an unverifiable archive at the project's
|
|
610
|
+
* base path, or more than one match refuses before any export, copy,
|
|
611
|
+
* overwrite or S3 mutation, with follow-up-project guidance;
|
|
612
|
+
* - otherwise a new identity is allocated, its dated export identity
|
|
613
|
+
* qualified by scope when a different project's archive already holds it.
|
|
614
|
+
*
|
|
615
|
+
* It writes nothing: no file, metadata, ref or remote object.
|
|
616
|
+
*/
|
|
617
|
+
export async function evaluateArchiveRetryIdentity(options, dependencies = {}) {
|
|
618
|
+
const generationReason = options.newGeneration
|
|
619
|
+
? validateNewGenerationReason(options.newGeneration.reason)
|
|
620
|
+
: null;
|
|
621
|
+
if (generationReason !== null &&
|
|
622
|
+
(options.scope === 'synced' || options.persistedSnapshot)) {
|
|
623
|
+
throw new ArchiveIdentityError('archive-generation-synced', `--new-generation applies to shared and local projects; synced project \`${options.projectName}\` is terminal at its completed ref. ${followUpGuidance(options.projectName)}`, 1);
|
|
624
|
+
}
|
|
625
|
+
const resolveTarget = dependencies.resolveArchiveProjectTarget ?? resolveArchiveProjectTarget;
|
|
626
|
+
const { resolveArchiveProjectTarget: _resolver, ...targetDependencies } = dependencies;
|
|
627
|
+
const target = await resolveTarget({
|
|
628
|
+
repoRoot: options.repoRoot,
|
|
629
|
+
projectsRoot: options.projectsRoot,
|
|
630
|
+
projectName: options.projectName,
|
|
631
|
+
...(options.persistedSnapshot
|
|
632
|
+
? {
|
|
633
|
+
archiveSnapshot: options.persistedSnapshot,
|
|
634
|
+
archiveScope: options.scope,
|
|
635
|
+
}
|
|
636
|
+
: {}),
|
|
637
|
+
}, targetDependencies);
|
|
638
|
+
const sourcePresent = await pathExists(options.projectPath);
|
|
639
|
+
const manifestHash = sourcePresent
|
|
640
|
+
? await computeArchiveManifestHash(options.projectPath)
|
|
641
|
+
: null;
|
|
642
|
+
const base = {
|
|
643
|
+
projectName: options.projectName,
|
|
644
|
+
scope: options.scope,
|
|
645
|
+
manifestHash,
|
|
646
|
+
sourcePresent,
|
|
647
|
+
target,
|
|
648
|
+
};
|
|
649
|
+
if (options.persistedSnapshot) {
|
|
650
|
+
let existing = null;
|
|
651
|
+
try {
|
|
652
|
+
existing = readCandidateMetadata(JSON.parse(await readFile(join(target.archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
existing = null;
|
|
656
|
+
}
|
|
657
|
+
const archiveExists = await pathExists(target.archivePath);
|
|
658
|
+
if (archiveExists && existing !== null) {
|
|
659
|
+
// The synced source checkout may legitimately differ from the bound
|
|
660
|
+
// snapshot (it is pinned by the source ref), so only the recorded
|
|
661
|
+
// manifest is checked here.
|
|
662
|
+
await assertRetryCandidateIntact(options.projectName, target.archivePath, existing, null);
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
...base,
|
|
666
|
+
...identityFromMetadata(existing),
|
|
667
|
+
disposition: archiveExists ? 'retry' : 'new',
|
|
668
|
+
snapshotName: options.persistedSnapshot,
|
|
669
|
+
archivePath: target.archivePath,
|
|
670
|
+
archiveExists,
|
|
671
|
+
generation: existing?.generation ?? 1,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
if (generationReason !== null && !sourcePresent) {
|
|
675
|
+
throw new ArchiveIdentityError('archive-generation-source-absent', `--new-generation needs the reopened project at \`${options.projectPath}\`; it is absent. Retry without --new-generation to resume an existing archive.`, 1);
|
|
676
|
+
}
|
|
677
|
+
const archiveBasePath = resolveCompletionArchivePath(target.archiveRepoRoot, target.archiveProjectPath);
|
|
678
|
+
const candidates = await readArchiveIdentityCandidates(archiveBasePath, options.projectName);
|
|
679
|
+
const sameIdentity = candidates.filter((candidate) => candidate.metadata?.scope === options.scope);
|
|
680
|
+
const matches = [];
|
|
681
|
+
if (sourcePresent) {
|
|
682
|
+
for (const candidate of sameIdentity) {
|
|
683
|
+
const candidateHash = candidate.metadata?.manifestHash ??
|
|
684
|
+
(await computeArchiveManifestHash(candidate.archivePath));
|
|
685
|
+
const refMatches = options.sourceRefSha === undefined ||
|
|
686
|
+
candidate.metadata?.sourceRefSha === undefined ||
|
|
687
|
+
candidate.metadata.sourceRefSha === options.sourceRefSha;
|
|
688
|
+
if (candidateHash === manifestHash && refMatches) {
|
|
689
|
+
matches.push(candidate);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
else if (options.scope !== 'synced' || sameIdentity.length === 1) {
|
|
694
|
+
// An absent source resumes its latest generation. A recordless synced
|
|
695
|
+
// apply binds its snapshot through the completed ref before this
|
|
696
|
+
// evaluator runs, so an unbound synced preview with several candidates
|
|
697
|
+
// stays unresolved.
|
|
698
|
+
const latest = Math.max(0, ...sameIdentity.map(generationOf));
|
|
699
|
+
matches.push(...sameIdentity.filter((candidate) => generationOf(candidate) === latest));
|
|
700
|
+
}
|
|
701
|
+
if (matches.length > 1) {
|
|
702
|
+
throw new ArchiveIdentityError('archive-retry-ambiguous', `Project \`${options.projectName}\` (${options.scope}) matches ${matches.length} existing archives (${matches
|
|
703
|
+
.map((match) => `\`${match.archivePath}\``)
|
|
704
|
+
.join(', ')}); refusing an ambiguous archive retry before any export, copy or S3 mutation. Keep exactly one archive of this project before retrying. ${followUpGuidance(options.projectName)}`, 1);
|
|
705
|
+
}
|
|
706
|
+
if (matches.length === 1) {
|
|
707
|
+
// An identical retry, a source-absent resume, or a replayed
|
|
708
|
+
// `--new-generation` request (same predecessor and content, so the same
|
|
709
|
+
// generation request id) all reuse the persisted identity.
|
|
710
|
+
const match = matches[0];
|
|
711
|
+
await assertRetryCandidateIntact(options.projectName, match.archivePath, match.metadata, manifestHash);
|
|
712
|
+
return {
|
|
713
|
+
...base,
|
|
714
|
+
...identityFromMetadata(match.metadata),
|
|
715
|
+
disposition: 'retry',
|
|
716
|
+
snapshotName: match.metadata.snapshotName,
|
|
717
|
+
archivePath: match.archivePath,
|
|
718
|
+
archiveExists: true,
|
|
719
|
+
generation: match.metadata.generation ?? 1,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
if (generationReason !== null) {
|
|
723
|
+
return allocateArchiveGeneration(options, base, archiveBasePath, candidates, sameIdentity, generationReason);
|
|
724
|
+
}
|
|
725
|
+
if (sourcePresent) {
|
|
726
|
+
// Name the latest generation, the archive the operator would build on.
|
|
727
|
+
const latestGeneration = Math.max(0, ...sameIdentity.map(generationOf));
|
|
728
|
+
const conflict = sameIdentity.find((candidate) => generationOf(candidate) === latestGeneration) ?? candidates.find((candidate) => candidate.metadata === null);
|
|
729
|
+
if (conflict) {
|
|
730
|
+
throw conflict.metadata
|
|
731
|
+
? new ArchiveIdentityError('archive-retry-conflict', `Project \`${options.projectName}\` (${options.scope}) was already archived as \`${conflict.metadata.snapshotName}\` (generation ${generationOf(conflict)}${conflict.metadata.allocation === 'pending' ? ', allocation: pending' : ''}) at \`${conflict.archivePath}\`, and its retained artifacts differ from that snapshot; refusing to overwrite the archive or create a second copy before any export, copy or S3 mutation. ${followUpGuidance(options.projectName)}`)
|
|
732
|
+
: new ArchiveIdentityError('archive-occupant-unverifiable', `\`${conflict.archivePath}\` already exists without readable archive metadata, so it cannot be verified as an earlier archive of \`${options.projectName}\`; refusing to overwrite it or create a second copy before any export, copy or S3 mutation. ${occupantGuidance(options.projectName)}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// The local path and the dated export identity derive from the identity,
|
|
736
|
+
// never the clock: a free or empty base path is used as is; when another
|
|
737
|
+
// project's archive holds the base path or the dated identity, both carry
|
|
738
|
+
// the scope (`<project>-<scope>`, `<YYYYMMDD>-<project>-<scope>`), the name
|
|
739
|
+
// `oat repo archive sync` hydrates the snapshot to.
|
|
740
|
+
const datedIdentity = buildArchiveSnapshotName(options.projectName, options.timestamp);
|
|
741
|
+
const baseOccupied = (await pathExists(archiveBasePath)) &&
|
|
742
|
+
(await readdir(archiveBasePath)).length > 0;
|
|
743
|
+
const scopeQualified = baseOccupied ||
|
|
744
|
+
candidates.some((candidate) => candidate.metadata?.snapshotName === datedIdentity);
|
|
745
|
+
const localName = formatArchiveLocalName(options.projectName, 1, scopeQualified ? options.scope : null);
|
|
746
|
+
const snapshotName = buildArchiveSnapshotName(localName, options.timestamp);
|
|
747
|
+
const archivePath = scopeQualified
|
|
748
|
+
? join(dirname(archiveBasePath), localName)
|
|
749
|
+
: archiveBasePath;
|
|
750
|
+
// One rule for empty directories at both paths: an empty one is free.
|
|
751
|
+
if (scopeQualified &&
|
|
752
|
+
(await pathExists(archivePath)) &&
|
|
753
|
+
!(await isEmptyDirectory(archivePath))) {
|
|
754
|
+
throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${archivePath}\` already exists and is not an archive of \`${options.projectName}\` (${options.scope}); refusing to overwrite it or create a second copy before any export, copy or S3 mutation. ${occupantGuidance(options.projectName)}`);
|
|
755
|
+
}
|
|
756
|
+
return {
|
|
757
|
+
...base,
|
|
758
|
+
disposition: 'new',
|
|
759
|
+
snapshotName,
|
|
760
|
+
archivePath,
|
|
761
|
+
archiveExists: false,
|
|
762
|
+
generation: 1,
|
|
763
|
+
predecessor: null,
|
|
764
|
+
generationRequest: null,
|
|
765
|
+
generationReason: null,
|
|
766
|
+
allocationPending: false,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Resolves `--new-generation`: exactly one verified latest generation of the
|
|
771
|
+
* same project and scope is the predecessor, and the successor gets the next
|
|
772
|
+
* generation number, its own local path, dated export identity and generation
|
|
773
|
+
* request id. Nothing is written here; the apply persists the allocation
|
|
774
|
+
* before any side effect.
|
|
775
|
+
*/
|
|
776
|
+
async function allocateArchiveGeneration(options, base, archiveBasePath, candidates, sameIdentity, generationReason) {
|
|
777
|
+
const unverifiable = candidates.find((candidate) => candidate.metadata === null);
|
|
778
|
+
if (unverifiable) {
|
|
779
|
+
throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${unverifiable.archivePath}\` already exists without readable archive metadata, so it cannot be a verified predecessor of \`${options.projectName}\`; refusing to allocate a generation. ${occupantGuidance(options.projectName)}`);
|
|
780
|
+
}
|
|
781
|
+
if (sameIdentity.length === 0) {
|
|
782
|
+
throw new ArchiveIdentityError('archive-generation-no-predecessor', `--new-generation needs an archived predecessor of \`${options.projectName}\` (${options.scope}), and none exists; archive the project without --new-generation.`, 1);
|
|
783
|
+
}
|
|
784
|
+
const latest = Math.max(...sameIdentity.map(generationOf));
|
|
785
|
+
const heads = sameIdentity.filter((candidate) => generationOf(candidate) === latest);
|
|
786
|
+
if (heads.length !== 1) {
|
|
787
|
+
throw new ArchiveIdentityError('archive-generation-ambiguous-predecessor', `--new-generation for \`${options.projectName}\` (${options.scope}) needs exactly one predecessor, but ${heads.length} archives hold generation ${latest} (${heads
|
|
788
|
+
.map((head) => `\`${head.archivePath}\``)
|
|
789
|
+
.join(', ')}); refusing to allocate a generation. ${followUpGuidance(options.projectName)}`, 1);
|
|
790
|
+
}
|
|
791
|
+
const predecessor = heads[0].metadata;
|
|
792
|
+
if (predecessor.allocation === 'pending') {
|
|
793
|
+
throw new ArchiveIdentityError('archive-generation-pending', `Generation ${latest} of \`${options.projectName}\` (\`${predecessor.snapshotName}\`) is still being archived; finish it by retrying with its original content before requesting another generation.`, 1);
|
|
794
|
+
}
|
|
795
|
+
const generation = latest + 1;
|
|
796
|
+
const datedIdentity = buildArchiveSnapshotName(formatArchiveLocalName(options.projectName, generation, null), options.timestamp);
|
|
797
|
+
const generationPath = join(dirname(archiveBasePath), formatArchiveLocalName(options.projectName, generation, null));
|
|
798
|
+
// The generation-1 rule, applied to successors: when the same project's
|
|
799
|
+
// archive from another scope already holds `<project>-gN` or the dated
|
|
800
|
+
// identity, both the local path and the export identity carry the scope
|
|
801
|
+
// (`<project>-gN-<scope>`, `<YYYYMMDD>-<project>-gN-<scope>`), the name
|
|
802
|
+
// `oat repo archive sync` hydrates the snapshot to. Anything else at the
|
|
803
|
+
// path still refuses.
|
|
804
|
+
// A family keeps its qualifier: when the predecessor's own archive name
|
|
805
|
+
// carries this scope (`<project>[-gN]-<scope>`), every successor does too,
|
|
806
|
+
// whatever order the scopes reach the next generation in, so
|
|
807
|
+
// `oat repo archive sync <project>-<scope>` keeps restoring its latest one.
|
|
808
|
+
const predecessorQualified = basename(heads[0].archivePath) ===
|
|
809
|
+
formatArchiveLocalName(options.projectName, latest, options.scope);
|
|
810
|
+
const pathHeldByOtherScope = candidates.some((candidate) => candidate.archivePath === generationPath &&
|
|
811
|
+
candidate.metadata !== null &&
|
|
812
|
+
candidate.metadata.scope !== options.scope);
|
|
813
|
+
const scopeQualified = predecessorQualified ||
|
|
814
|
+
pathHeldByOtherScope ||
|
|
815
|
+
candidates.some((candidate) => candidate.metadata?.snapshotName === datedIdentity);
|
|
816
|
+
const localName = formatArchiveLocalName(options.projectName, generation, scopeQualified ? options.scope : null);
|
|
817
|
+
const snapshotName = buildArchiveSnapshotName(localName, options.timestamp);
|
|
818
|
+
const archivePath = join(dirname(archiveBasePath), localName);
|
|
819
|
+
// Same rule as the base and scope-qualified paths: an empty directory is free.
|
|
820
|
+
if ((await pathExists(archivePath)) &&
|
|
821
|
+
!(await isEmptyDirectory(archivePath))) {
|
|
822
|
+
throw new ArchiveIdentityError('archive-generation-path-occupied', `\`${archivePath}\` already exists and is not generation ${generation} of \`${options.projectName}\` (${options.scope}); refusing to overwrite it.`, 1);
|
|
823
|
+
}
|
|
824
|
+
return {
|
|
825
|
+
...base,
|
|
826
|
+
disposition: 'new',
|
|
827
|
+
snapshotName,
|
|
828
|
+
archivePath,
|
|
829
|
+
archiveExists: false,
|
|
830
|
+
generation,
|
|
831
|
+
predecessor: predecessor.snapshotName,
|
|
832
|
+
generationRequest: sha256(JSON.stringify([
|
|
833
|
+
'oat-archive-generation/1',
|
|
834
|
+
options.projectName,
|
|
835
|
+
options.scope,
|
|
836
|
+
predecessor.snapshotName,
|
|
837
|
+
base.manifestHash,
|
|
838
|
+
])),
|
|
839
|
+
generationReason,
|
|
840
|
+
allocationPending: false,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Finalizes the export-safe source locators of a summary bound for the tracked
|
|
845
|
+
* export: `oat-project:<slug>#<entry-id>` tokens of this project become
|
|
846
|
+
* `oat-archive:<snapshot>#<entry-id>`, and a relative Markdown link to the
|
|
847
|
+
* archived-only project log or legacy learnings file becomes plain text naming
|
|
848
|
+
* the archive identity; a relative link to unarchived review or PR evidence
|
|
849
|
+
* (`reviews/…`, `pr/…`) becomes plain provenance naming the file. Every other
|
|
850
|
+
* byte is unchanged.
|
|
851
|
+
*/
|
|
852
|
+
export function finalizeSummaryArchiveLocators(text, projectName, snapshotName) {
|
|
853
|
+
const slug = projectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
854
|
+
return text
|
|
855
|
+
.replace(new RegExp(`${PROJECT_LOG_LOCATOR_PREFIX}${slug}#([a-z0-9][a-z0-9-]*)`, 'g'), (_token, entryId) => `${ARCHIVE_LOCATOR_PREFIX}${snapshotName}#${entryId}`)
|
|
856
|
+
.replace(/\[([^\]\n]+)\]\((?:\.\/)?(project-log\.md|oat-execution-learnings\.md)(?:#[^)\s]*)?\)/g, (_link, label, file) => `${label} (${ARCHIVE_LOCATOR_PREFIX}${snapshotName}/${file})`)
|
|
857
|
+
.replace(
|
|
858
|
+
// Review and PR evidence are never archived (D2), so a local link to
|
|
859
|
+
// either cannot resolve from the export: keep its label and file name as
|
|
860
|
+
// plain provenance. Absolute and external URLs do not match.
|
|
861
|
+
/\[([^\]\n]+)\]\((?:\.\/)?((reviews|pr)\/[^)\s#]+)(?:#[^)\s]*)?\)/g, (_link, label, file, kind) => `${label} (${kind === 'pr' ? 'PR artifact' : 'review'} \`${file}\`, local to the project; not archived)`);
|
|
862
|
+
}
|
|
340
863
|
async function writeArchiveSnapshotMetadata(archivePath, metadata) {
|
|
341
864
|
await writeFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
|
|
342
865
|
}
|
|
@@ -356,7 +879,13 @@ async function verifyArchiveSnapshotMetadata(archivePath, expected) {
|
|
|
356
879
|
throw new CliError(`Existing archive \`${archivePath}\` does not match persisted snapshot \`${expected.snapshotName}\`; refusing to overwrite it.`);
|
|
357
880
|
}
|
|
358
881
|
}
|
|
359
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Binds a recordless synced project's archive identity through its
|
|
884
|
+
* authoritative completed ref (read-only: it scans local archive metadata and
|
|
885
|
+
* probes refs). Apply and dry-run both use it, so a preview reports the
|
|
886
|
+
* snapshot the apply would bind, or refuses as the apply would.
|
|
887
|
+
*/
|
|
888
|
+
export async function resolveRecordlessSyncedArchiveIdentity(options, target, git, dependencies) {
|
|
360
889
|
const tentativeTarget = await resolveArchiveProjectTarget(options, dependencies);
|
|
361
890
|
assertDurableArchiveProjectTarget(tentativeTarget);
|
|
362
891
|
const archiveRoot = dirname(tentativeTarget.archivePath);
|
|
@@ -397,7 +926,7 @@ async function resolveRecordlessSyncedArchiveIdentity(options, target, git, depe
|
|
|
397
926
|
}
|
|
398
927
|
}
|
|
399
928
|
if (candidates.length === 0) {
|
|
400
|
-
throw new
|
|
929
|
+
throw new ArchiveIdentityError('archive-synced-recordless-unbound', `Synced project ${options.projectName} has no active record and no persisted synced archive identity; refusing to create a replacement active record or a new snapshot.`, 2);
|
|
401
930
|
}
|
|
402
931
|
const probe = dependencies.probeSyncedTerminalRefs ?? probeSyncedTerminalRefs;
|
|
403
932
|
const matches = [];
|
|
@@ -410,13 +939,13 @@ async function resolveRecordlessSyncedArchiveIdentity(options, target, git, depe
|
|
|
410
939
|
}
|
|
411
940
|
}
|
|
412
941
|
if (matches.length !== 1) {
|
|
413
|
-
throw
|
|
414
|
-
? `Synced project ${options.projectName} has no active record, but no persisted archive identity matches its authoritative completed ref; refusing terminal retry before export or S3 mutation
|
|
415
|
-
: `Synced project ${options.projectName} has no active record and multiple persisted archives match its authoritative completed ref; refusing an ambiguous terminal retry.`, 2);
|
|
942
|
+
throw matches.length === 0
|
|
943
|
+
? new ArchiveIdentityError('archive-synced-recordless-unbound', `Synced project ${options.projectName} has no active record, but no persisted archive identity matches its authoritative completed ref; refusing terminal retry before export or S3 mutation.`, 2)
|
|
944
|
+
: new ArchiveIdentityError('archive-synced-recordless-ambiguous', `Synced project ${options.projectName} has no active record and multiple persisted archives match its authoritative completed ref; refusing an ambiguous terminal retry.`, 2);
|
|
416
945
|
}
|
|
417
946
|
return matches[0];
|
|
418
947
|
}
|
|
419
|
-
async function exportProjectSummary(archivePath, snapshotName, summaryExportPath, repoRoot, dependencies, recapExport) {
|
|
948
|
+
async function exportProjectSummary(archivePath, projectName, snapshotName, summaryExportPath, repoRoot, dependencies, recapExport) {
|
|
420
949
|
const summarySource = join(archivePath, 'summary.md');
|
|
421
950
|
const exists = dependencies.fileExists ?? fileExists;
|
|
422
951
|
if (!(await exists(summarySource))) {
|
|
@@ -425,13 +954,16 @@ async function exportProjectSummary(archivePath, snapshotName, summaryExportPath
|
|
|
425
954
|
const summaryTarget = join(repoRoot, summaryExportPath, `${snapshotName}.md`);
|
|
426
955
|
const copySummary = dependencies.copySingleFile ?? copySingleFile;
|
|
427
956
|
const sourceContents = await readFile(summarySource);
|
|
428
|
-
|
|
957
|
+
const sourceText = sourceContents.toString('utf8');
|
|
958
|
+
const finalizedText = finalizeSummaryArchiveLocators(sourceText, projectName, snapshotName);
|
|
959
|
+
// Untouched bytes are exported as copied; only a finalized locator rewrites.
|
|
960
|
+
let expectedContents = finalizedText === sourceText ? sourceContents : Buffer.from(finalizedText);
|
|
429
961
|
if (recapExport) {
|
|
430
962
|
const pageLink = relative(dirname(summaryTarget), recapExport.exportRoot)
|
|
431
963
|
.split(sep)
|
|
432
964
|
.join('/');
|
|
433
965
|
const receipt = `Recap: [View the recap](${pageLink})\n\nRun: \`${recapExport.runId}\` \nOriginal page SHA-256: \`${recapExport.page.originalSha256}\` \nExported page SHA-256: \`${recapExport.page.exportedSha256}\`\n`;
|
|
434
|
-
const text =
|
|
966
|
+
const text = finalizedText;
|
|
435
967
|
const outcome = /(^## Explainer Outcome[^\n]*\n)([\s\S]*?)(?=^## |$(?![\s\S]))/m;
|
|
436
968
|
const rewriteLinks = (body) => body.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (markdownLink, label, href) => /(?:explainers\/|project-recaps\/)/.test(href)
|
|
437
969
|
? `[${label}](${pageLink})`
|
|
@@ -448,8 +980,9 @@ async function exportProjectSummary(archivePath, snapshotName, summaryExportPath
|
|
|
448
980
|
return summaryTarget;
|
|
449
981
|
}
|
|
450
982
|
await copySummary(summarySource, summaryTarget);
|
|
451
|
-
if (
|
|
983
|
+
if (!expectedContents.equals(sourceContents)) {
|
|
452
984
|
await writeFile(summaryTarget, expectedContents);
|
|
985
|
+
}
|
|
453
986
|
if (!expectedContents.equals(await readFile(summaryTarget))) {
|
|
454
987
|
throw new CliError(`Summary export \`${summaryTarget}\` failed byte verification.`);
|
|
455
988
|
}
|
|
@@ -1155,7 +1688,6 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1155
1688
|
const ensureAccess = dependencies.ensureS3ArchiveAccess ?? ensureS3ArchiveAccess;
|
|
1156
1689
|
const execFile = dependencies.execFile ?? execFileAsync;
|
|
1157
1690
|
const timestamp = dependencies.timestamp?.() ?? new Date().toISOString();
|
|
1158
|
-
const snapshotName = buildArchiveSnapshotName(options.projectName, timestamp);
|
|
1159
1691
|
const syncedRoot = resolveScopeRoot(options.repoRoot, options.projectsRoot, 'synced');
|
|
1160
1692
|
const recordPath = syncedRecordPath(syncedRoot, options.projectName);
|
|
1161
1693
|
const readRecord = dependencies.readSyncedRecord ?? readSyncedRecord;
|
|
@@ -1206,19 +1738,24 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1206
1738
|
throw new CliError(`Archive retry for ${options.projectName} is bound to source ref ${activeRecord.archiveSourceRefSha}, but ${syncTarget.ref} is now ${sourceRefSha}. Restore the retained ref to the recorded commit to finish this archive transaction, or preserve the existing snapshot and start a separately reviewed recovery; refusing to accept stale archive content.`, 1);
|
|
1207
1739
|
}
|
|
1208
1740
|
}
|
|
1209
|
-
const
|
|
1741
|
+
const persistedSnapshot = activeRecord?.archiveSnapshot ?? recordlessIdentity?.metadata.snapshotName;
|
|
1742
|
+
// The shared read-only evaluator owns retry identity for apply and dry-run:
|
|
1743
|
+
// an identical retry reuses its snapshot, a conflicting or ambiguous one
|
|
1744
|
+
// refuses here, before any export, copy, overwrite or S3 mutation.
|
|
1745
|
+
const identity = await evaluateArchiveRetryIdentity({
|
|
1210
1746
|
repoRoot: options.repoRoot,
|
|
1211
1747
|
projectsRoot: options.projectsRoot,
|
|
1212
1748
|
projectName: options.projectName,
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1749
|
+
projectPath: options.projectPath,
|
|
1750
|
+
scope: projectScope,
|
|
1751
|
+
timestamp,
|
|
1752
|
+
...(persistedSnapshot ? { persistedSnapshot } : {}),
|
|
1753
|
+
...(sourceRefSha ? { sourceRefSha } : {}),
|
|
1754
|
+
...(options.newGenerationReason != null
|
|
1755
|
+
? { newGeneration: { reason: options.newGenerationReason } }
|
|
1220
1756
|
: {}),
|
|
1221
1757
|
}, dependencies);
|
|
1758
|
+
const archiveTarget = identity.target;
|
|
1222
1759
|
assertDurableArchiveProjectTarget(archiveTarget);
|
|
1223
1760
|
// Reassert the identity immediately before the first durable mutation. The
|
|
1224
1761
|
// archive transaction must never bind a descendant source to a sibling
|
|
@@ -1231,12 +1768,8 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1231
1768
|
canonicalProjectPath !== canonicalizePath(syncTarget.projectPath)) {
|
|
1232
1769
|
throw new CliError(`Synced archive identity for ${options.projectName} does not match its canonical checkout, record, and ref.`, 1);
|
|
1233
1770
|
}
|
|
1234
|
-
const archivePath =
|
|
1235
|
-
const exportIdentity =
|
|
1236
|
-
? (activeRecord?.archiveSnapshot ??
|
|
1237
|
-
recordlessIdentity?.metadata.snapshotName ??
|
|
1238
|
-
snapshotName)
|
|
1239
|
-
: snapshotName;
|
|
1771
|
+
const archivePath = identity.archivePath;
|
|
1772
|
+
const exportIdentity = identity.snapshotName;
|
|
1240
1773
|
const snapshotId = syncTarget ? exportIdentity : basename(archivePath);
|
|
1241
1774
|
const shouldPersistArchiveSnapshot = Boolean(syncTarget &&
|
|
1242
1775
|
activeRecord &&
|
|
@@ -1249,7 +1782,47 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1249
1782
|
};
|
|
1250
1783
|
await writeRecord(recordPath, activeRecord);
|
|
1251
1784
|
}
|
|
1252
|
-
const
|
|
1785
|
+
const generationMetadata = identity.predecessor
|
|
1786
|
+
? {
|
|
1787
|
+
predecessor: identity.predecessor,
|
|
1788
|
+
...(identity.generationRequest
|
|
1789
|
+
? { generationRequest: identity.generationRequest }
|
|
1790
|
+
: {}),
|
|
1791
|
+
...(identity.generationReason
|
|
1792
|
+
? { generationReason: identity.generationReason }
|
|
1793
|
+
: {}),
|
|
1794
|
+
}
|
|
1795
|
+
: {};
|
|
1796
|
+
let allocationPending = identity.allocationPending;
|
|
1797
|
+
// What this in-process call created, so a failed copy removes nothing else.
|
|
1798
|
+
// OAT does not serialize concurrent archive runs of one project.
|
|
1799
|
+
let createdAllocation = false;
|
|
1800
|
+
let createdDirectory = false;
|
|
1801
|
+
if (identity.disposition === 'new') {
|
|
1802
|
+
// Persist the new archive (a first archive or a successor generation, with
|
|
1803
|
+
// its predecessor binding) before any export, copy or S3 side effect, so
|
|
1804
|
+
// an interrupted run resumes this identity instead of allocating from the
|
|
1805
|
+
// current date. A stray occupant at the path refuses and is not touched.
|
|
1806
|
+
createdDirectory = !(await pathExists(archivePath));
|
|
1807
|
+
await makeDir(archivePath);
|
|
1808
|
+
await refuseForeignOccupant(archivePath, options.projectName, projectScope);
|
|
1809
|
+
await writeArchiveSnapshotMetadata(archivePath, {
|
|
1810
|
+
projectName: options.projectName,
|
|
1811
|
+
snapshotName: exportIdentity,
|
|
1812
|
+
scope: projectScope,
|
|
1813
|
+
...(sourceRefSha ? { sourceRefSha } : {}),
|
|
1814
|
+
...(identity.manifestHash ? { manifestHash: identity.manifestHash } : {}),
|
|
1815
|
+
generation: identity.generation,
|
|
1816
|
+
...generationMetadata,
|
|
1817
|
+
allocation: 'pending',
|
|
1818
|
+
});
|
|
1819
|
+
allocationPending = true;
|
|
1820
|
+
createdAllocation = true;
|
|
1821
|
+
}
|
|
1822
|
+
// An empty directory at the identity's path holds no archive; the copy
|
|
1823
|
+
// fills it in place.
|
|
1824
|
+
const archiveExists = (await (dependencies.dirExists ?? dirExists)(archivePath)) &&
|
|
1825
|
+
!(await isEmptyDirectory(archivePath));
|
|
1253
1826
|
if (archiveExists) {
|
|
1254
1827
|
await verifyArchiveSnapshotMetadata(archivePath, {
|
|
1255
1828
|
projectName: options.projectName,
|
|
@@ -1263,20 +1836,56 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1263
1836
|
: archivePath;
|
|
1264
1837
|
let attemptedProjectRecapExport = await exportSelectedProjectRecap({ ...options, projectPath: projectSourcePath }, exportIdentity, dependencies);
|
|
1265
1838
|
let projectRecapExport = attemptedProjectRecapExport?.export ?? null;
|
|
1266
|
-
if (!archiveExists) {
|
|
1839
|
+
if (!archiveExists || allocationPending) {
|
|
1267
1840
|
try {
|
|
1268
1841
|
await makeDir(dirname(archivePath));
|
|
1269
|
-
|
|
1270
|
-
|
|
1842
|
+
// A resumed pending allocation (an interrupted earlier run) holds a
|
|
1843
|
+
// partial copy of this identity: clear it, never a completed archive.
|
|
1844
|
+
if (allocationPending &&
|
|
1845
|
+
!createdAllocation &&
|
|
1846
|
+
!(await clearPendingGeneration(archivePath, removePath))) {
|
|
1847
|
+
throw new ArchiveIdentityError('archive-retry-conflict', `Archive \`${archivePath}\` of \`${options.projectName}\` is no longer a pending allocation; refusing to clear or overwrite it before any copy or S3 mutation. Retry: an identical retry reuses the completed archive.`);
|
|
1848
|
+
}
|
|
1849
|
+
await copyProjectDirectory(options.projectPath, archivePath,
|
|
1850
|
+
// Archive-owned entries are never copied from the source: a source
|
|
1851
|
+
// reopened by copying its archive back would otherwise overwrite this
|
|
1852
|
+
// generation's pending allocation with its predecessor's identity.
|
|
1853
|
+
(_sourcePath, relativePath) => !ARCHIVE_RETENTION_EXCLUSIONS.includes(relativePath) &&
|
|
1854
|
+
!ARCHIVE_OWNED_ENTRIES.includes(relativePath));
|
|
1271
1855
|
await writeArchiveSnapshotMetadata(archivePath, {
|
|
1272
1856
|
projectName: options.projectName,
|
|
1273
1857
|
snapshotName: exportIdentity,
|
|
1274
1858
|
scope: projectScope,
|
|
1275
1859
|
...(sourceRefSha ? { sourceRefSha } : {}),
|
|
1860
|
+
manifestHash: await computeArchiveManifestHash(archivePath),
|
|
1861
|
+
generation: identity.generation,
|
|
1862
|
+
...generationMetadata,
|
|
1276
1863
|
});
|
|
1277
1864
|
}
|
|
1278
1865
|
catch (error) {
|
|
1279
|
-
|
|
1866
|
+
// Only a still-pending allocation is cleaned up; an archive whose
|
|
1867
|
+
// metadata is final is never removed or cleared.
|
|
1868
|
+
const stillPending = (await readArchiveMetadataAt(archivePath))?.allocation === 'pending';
|
|
1869
|
+
if (stillPending && createdAllocation && identity.generation === 1) {
|
|
1870
|
+
// A first archive this call created is removed whole, so a retry
|
|
1871
|
+
// starts from one clean target.
|
|
1872
|
+
if (createdDirectory) {
|
|
1873
|
+
await removePath(archivePath, { recursive: true, force: true });
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
for (const entry of await readdir(archivePath)) {
|
|
1877
|
+
await removePath(join(archivePath, entry), {
|
|
1878
|
+
recursive: true,
|
|
1879
|
+
force: true,
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
else if (stillPending) {
|
|
1885
|
+
// A successor, or any resumed allocation, keeps its pending metadata
|
|
1886
|
+
// and drops the partial copy, so the retry resumes it.
|
|
1887
|
+
await clearPendingGeneration(archivePath, removePath);
|
|
1888
|
+
}
|
|
1280
1889
|
await removeAttemptRecapExport(attemptedProjectRecapExport, removePath, dependencies.renamePath ?? rename);
|
|
1281
1890
|
throw error;
|
|
1282
1891
|
}
|
|
@@ -1299,10 +1908,13 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1299
1908
|
throw error;
|
|
1300
1909
|
}
|
|
1301
1910
|
const warnings = [];
|
|
1911
|
+
const replayWarning = newGenerationReplayWarning(identity, options.newGenerationReason);
|
|
1912
|
+
if (replayWarning)
|
|
1913
|
+
warnings.push(replayWarning);
|
|
1302
1914
|
let summaryExportFile = null;
|
|
1303
1915
|
if (options.summaryExportPath) {
|
|
1304
1916
|
try {
|
|
1305
|
-
summaryExportFile = await exportProjectSummary(archivePath, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies, projectRecapExport);
|
|
1917
|
+
summaryExportFile = await exportProjectSummary(archivePath, options.projectName, exportIdentity, options.summaryExportPath, options.repoRoot, dependencies, projectRecapExport);
|
|
1306
1918
|
}
|
|
1307
1919
|
catch (error) {
|
|
1308
1920
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1433,8 +2045,60 @@ export async function archiveProjectOnCompletion(options, dependencies = {}) {
|
|
|
1433
2045
|
snapshotId,
|
|
1434
2046
|
terminalReceipt,
|
|
1435
2047
|
recordRetired,
|
|
2048
|
+
generation: identity.generation,
|
|
2049
|
+
predecessorSnapshot: identity.predecessor,
|
|
1436
2050
|
};
|
|
1437
2051
|
}
|
|
2052
|
+
async function isEmptyDirectory(target) {
|
|
2053
|
+
try {
|
|
2054
|
+
return (await readdir(target)).length === 0;
|
|
2055
|
+
}
|
|
2056
|
+
catch {
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
async function readArchiveMetadataAt(archivePath) {
|
|
2061
|
+
try {
|
|
2062
|
+
return readCandidateMetadata(JSON.parse(await readFile(join(archivePath, ARCHIVE_SNAPSHOT_METADATA_FILENAME), 'utf8')));
|
|
2063
|
+
}
|
|
2064
|
+
catch {
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Before a new archive's metadata is written: anything already at the path
|
|
2070
|
+
* (an older CLI mid-copy, a person or a tool) does not identify itself as this
|
|
2071
|
+
* archive, so the run refuses and leaves it untouched.
|
|
2072
|
+
*/
|
|
2073
|
+
async function refuseForeignOccupant(archivePath, projectName, scope) {
|
|
2074
|
+
const occupants = await readdir(archivePath);
|
|
2075
|
+
if (occupants.length === 0) {
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
throw new ArchiveIdentityError('archive-occupant-unverifiable', `\`${archivePath}\` gained content that does not identify itself as an archive of \`${projectName}\` (${scope}) after this run evaluated it (${occupants
|
|
2079
|
+
.sort()
|
|
2080
|
+
.map((entry) => `\`${entry}\``)
|
|
2081
|
+
.join(', ')}); refusing to overwrite or clear it before any export, copy or S3 mutation. ${occupantGuidance(projectName)}`);
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Removes everything but the allocation metadata from a generation that is
|
|
2085
|
+
* still a pending allocation, and reports whether it did. An archive whose
|
|
2086
|
+
* metadata is final is never cleared.
|
|
2087
|
+
*/
|
|
2088
|
+
async function clearPendingGeneration(archivePath, removePath) {
|
|
2089
|
+
if ((await readArchiveMetadataAt(archivePath))?.allocation !== 'pending') {
|
|
2090
|
+
return false;
|
|
2091
|
+
}
|
|
2092
|
+
for (const entry of await readdir(archivePath)) {
|
|
2093
|
+
if (entry === ARCHIVE_SNAPSHOT_METADATA_FILENAME)
|
|
2094
|
+
continue;
|
|
2095
|
+
await removePath(join(archivePath, entry), {
|
|
2096
|
+
recursive: true,
|
|
2097
|
+
force: true,
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
return true;
|
|
2101
|
+
}
|
|
1438
2102
|
async function recoverSyncedLifecycleCommit(repoRoot, pathspecs, projectName, expectedSubject, git) {
|
|
1439
2103
|
const [recordPathspec] = pathspecs;
|
|
1440
2104
|
if (!recordPathspec) {
|