@zq-silk/yui 0.11.1 → 0.11.3
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/README.md +44 -0
- package/dist/cli/commandCatalog.js +28 -1
- package/dist/commands/projectCommands.js +581 -2
- package/dist/commands/taskCommands.js +5 -1
- package/dist/commands/taskIntegrationCommands.js +2 -1
- package/dist/repository/checkoutSwap.js +61 -0
- package/dist/repository/gitWorkspace.js +36 -0
- package/dist/repository/project.js +53 -3
- package/dist/storage/migration/productionRegistry.js +88 -0
- package/dist/storage/sqliteStore.js +39 -2
- package/dist/storage/taskStore.js +34 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -294,6 +294,49 @@ files as dirty, preserves ignored files, and refuses missing remotes or refs and
|
|
|
294
294
|
When the configured branch is `HEAD`, refresh resolves the remote's symbolic default branch for that
|
|
295
295
|
operation and requires the checkout to be on that branch; detached or mismatched checkouts fail.
|
|
296
296
|
|
|
297
|
+
### Project lifecycle
|
|
298
|
+
|
|
299
|
+
Divergence and end-of-life are explicit, Operator-authority operations with fail-closed gates.
|
|
300
|
+
Every destructive command refuses a managed Task Session (run it from an Operator or user
|
|
301
|
+
terminal), an active Task binding, a dirty checkout, and an unreachable or unverified remote.
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
yui project diagnose app
|
|
305
|
+
yui project reset app
|
|
306
|
+
yui project reset app --discard-local
|
|
307
|
+
yui project replace app --discard-local
|
|
308
|
+
yui project retire app --reason "superseded by app-ng"
|
|
309
|
+
yui project delete app --confirm app
|
|
310
|
+
yui project delete app --checkout --confirm app
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`project reset` handles the divergence `project refresh` refuses. Without `--discard-local` it is
|
|
314
|
+
a dry run: it fetches and verifies the remote baseline, and when the checkout has diverged it
|
|
315
|
+
refuses while listing the exact local commits that would be discarded. With `--discard-local` it
|
|
316
|
+
hard-resets the clean checkout to the verified remote commit (a plain fast-forward when the
|
|
317
|
+
checkout is merely behind). `project replace` goes further for Home-managed checkouts: it clones
|
|
318
|
+
the remote into a staging directory, verifies both branches, copies the Yui-local refs
|
|
319
|
+
(`refs/heads/yui/`, `refs/yui/archive/`) so historical evidence keeps resolving, then swaps the
|
|
320
|
+
checkout on disk while the catalog record keeps its path. Replace refuses linked worktrees (Task
|
|
321
|
+
or Integration workspaces) and dirty checkouts, and requires `--discard-local`. The swap is
|
|
322
|
+
recoverable: the previous checkout is parked at a backup path and restored on any failure, a
|
|
323
|
+
catalog refusal rolls the swap back, and a crash mid-swap is healed on the next run (a crash
|
|
324
|
+
before the swap leaves only a removable staging clone).
|
|
325
|
+
|
|
326
|
+
`project retire` is the auditable soft deprecation: it records who retired the Project, when, and
|
|
327
|
+
why, while retaining the catalog record, checkout, and every historical
|
|
328
|
+
Task/Run/Review/Integration/Publication reference. A retired Project cannot be refreshed,
|
|
329
|
+
updated, migrated, reset, replaced, maintained through Knowledge writes (add/retire/propose/
|
|
330
|
+
accept/reject), or bound to new Tasks, WorkItems, or Integrations; Knowledge reads (`list`,
|
|
331
|
+
`show`, `proposals list/show`) stay open so the evidence stays auditable.
|
|
332
|
+
`project delete` is the separate hard-removal decision: it requires a retired Project, an exact
|
|
333
|
+
`--confirm <project-id>` acknowledgment, and fails closed while any Task record references the
|
|
334
|
+
Project. `--checkout` additionally removes the Home-managed checkout (external checkouts are
|
|
335
|
+
user-owned and must be removed manually): it first refuses linked worktrees and dirty checkouts,
|
|
336
|
+
then moves the checkout to a tombstone before removing the catalog record, restoring it on any
|
|
337
|
+
failure so the catalog and checkout never disagree unrecoverably. `project show` and
|
|
338
|
+
`project list` display the lifecycle status and retirement record.
|
|
339
|
+
|
|
297
340
|
Use `task context` as the first detailed read of an existing Task. It combines the Task, Brief, active Decisions, recent Milestones, Roles, current and recent WorkItems with their Runs, recent Messages, open and resolved InputRequests, and recent Events. Terminal output keeps histories and long text compact; `yui --json task context <task-id>` returns the complete records in the top-level `data` field.
|
|
298
341
|
|
|
299
342
|
Leader wakeups stay deliberately small: the wake envelope carries only the
|
|
@@ -1011,6 +1054,7 @@ yui config completion [bash|zsh|fish]
|
|
|
1011
1054
|
yui session enter|record|replace|reconcile
|
|
1012
1055
|
yui session stop --all
|
|
1013
1056
|
yui project add|clone|refresh|update|discover|list|show|knowledge
|
|
1057
|
+
yui project reset|replace|retire|delete
|
|
1014
1058
|
```
|
|
1015
1059
|
|
|
1016
1060
|
`yui update` stages the newly published package **side by side** — it never
|
|
@@ -1260,7 +1260,10 @@ export const ROOT_COMMAND = buildNode({
|
|
|
1260
1260
|
{
|
|
1261
1261
|
name: "project",
|
|
1262
1262
|
summary: "Manage Projects, stable checkouts, branches, and Yui knowledge.",
|
|
1263
|
-
sections: [
|
|
1263
|
+
sections: [
|
|
1264
|
+
{ id: "manage", title: "Commands", entries: ["add", "clone", "refresh", "diagnose", "migrate", "update", "discover", "list", "show", "knowledge"] },
|
|
1265
|
+
{ id: "lifecycle", title: "Lifecycle (Operator authority)", entries: ["reset", "replace", "retire", "delete"] }
|
|
1266
|
+
],
|
|
1264
1267
|
children: [
|
|
1265
1268
|
{
|
|
1266
1269
|
name: "add",
|
|
@@ -1291,6 +1294,30 @@ export const ROOT_COMMAND = buildNode({
|
|
|
1291
1294
|
usage: "yui project migrate <project> [--preflight]",
|
|
1292
1295
|
options: ["--preflight"]
|
|
1293
1296
|
},
|
|
1297
|
+
{
|
|
1298
|
+
name: "reset",
|
|
1299
|
+
summary: "Hard-reset a canonical checkout to its verified remote baseline (Operator authority).",
|
|
1300
|
+
usage: "yui project reset <project> [--discard-local]",
|
|
1301
|
+
options: ["--discard-local"]
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
name: "replace",
|
|
1305
|
+
summary: "Re-clone a Home-managed checkout from its remote, preserving Yui refs (Operator authority).",
|
|
1306
|
+
usage: "yui project replace <project> --discard-local",
|
|
1307
|
+
options: ["--discard-local"]
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
name: "retire",
|
|
1311
|
+
summary: "Soft-deprecate a Project; record and evidence are retained (Operator authority).",
|
|
1312
|
+
usage: "yui project retire <project> --reason <text>",
|
|
1313
|
+
options: ["--reason"]
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
name: "delete",
|
|
1317
|
+
summary: "Remove a retired Project's catalog record and optionally its checkout (Operator authority).",
|
|
1318
|
+
usage: "yui project delete <project> [--checkout] --confirm <project-id>",
|
|
1319
|
+
options: ["--checkout", "--confirm"]
|
|
1320
|
+
},
|
|
1294
1321
|
{
|
|
1295
1322
|
name: "update",
|
|
1296
1323
|
summary: "Update a bound Project's aliases, remote, or branch refs.",
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { usageError } from "../errors/cliError.js";
|
|
5
5
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
6
|
+
import { healCheckoutSwap, restoreCheckoutSwap, swapManagedCheckout } from "../repository/checkoutSwap.js";
|
|
6
7
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
7
8
|
import { acquireProjectMaintenanceLock } from "../repository/projectMaintenanceLock.js";
|
|
8
|
-
import { addProjectKnowledge, addKnowledgeProposal, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
|
|
9
|
+
import { addProjectKnowledge, addKnowledgeProposal, assertProjectActive, decideKnowledgeProposal, createProject, findKnowledgeProposal, findKnowledgeProposalByFingerprint, knowledgeEvidenceDigest, knowledgeProposalFingerprint, managedProjectPath, planKnowledgeAcceptance, retireProject, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
|
|
9
10
|
import { projectActor } from "./taskActor.js";
|
|
10
11
|
export async function runProjectCommand(args, store, options = {}) {
|
|
11
12
|
const [command, ...rest] = args;
|
|
@@ -46,6 +47,18 @@ export async function runProjectCommand(args, store, options = {}) {
|
|
|
46
47
|
data: { project }
|
|
47
48
|
};
|
|
48
49
|
}
|
|
50
|
+
if (command === "reset") {
|
|
51
|
+
return resetProject(rest, store, options);
|
|
52
|
+
}
|
|
53
|
+
if (command === "replace") {
|
|
54
|
+
return replaceProject(rest, store, options);
|
|
55
|
+
}
|
|
56
|
+
if (command === "retire") {
|
|
57
|
+
return retireProjectCommand(rest, store, options);
|
|
58
|
+
}
|
|
59
|
+
if (command === "delete") {
|
|
60
|
+
return deleteProjectCommand(rest, store, options);
|
|
61
|
+
}
|
|
49
62
|
if (command === "discover") {
|
|
50
63
|
const discovered = await discoverProjects(rest, store, options);
|
|
51
64
|
return { output: renderDiscoveredProjects(discovered), data: { projects: discovered } };
|
|
@@ -77,6 +90,7 @@ async function refreshProject(args, store, options) {
|
|
|
77
90
|
throw usageError("Project refresh usage: yui project refresh <project>.");
|
|
78
91
|
}
|
|
79
92
|
const project = requireProject(store, args[0]);
|
|
93
|
+
assertProjectActive(project, "refresh");
|
|
80
94
|
if (project.remoteUrl === undefined) {
|
|
81
95
|
throw usageError(`Project refresh requires a remote URL: ${project.id}.`);
|
|
82
96
|
}
|
|
@@ -280,6 +294,7 @@ async function migrateProject(args, store, options) {
|
|
|
280
294
|
const usage = "Project migrate usage: yui project migrate <project> [--preflight].";
|
|
281
295
|
const parsed = parseMigrateArguments(args, usage);
|
|
282
296
|
const project = requireProject(store, parsed.project);
|
|
297
|
+
assertProjectActive(project, "migrate");
|
|
283
298
|
if (project.ownership === "managed") {
|
|
284
299
|
throw usageError(`Project is already Home-managed: ${project.id}.`);
|
|
285
300
|
}
|
|
@@ -475,6 +490,7 @@ async function updateProject(args, store, options) {
|
|
|
475
490
|
const usage = "Project update usage: yui project update <project> [--alias <name> ...|--clear-aliases] [--remote <url>|--clear-remote] [--stable <ref>] [--development <ref>].";
|
|
476
491
|
const parsed = parseProjectUpdateArguments(args, usage);
|
|
477
492
|
const current = requireProject(store, parsed.reference);
|
|
493
|
+
assertProjectActive(current, "update");
|
|
478
494
|
const now = (options.now ?? (() => new Date()))();
|
|
479
495
|
const patch = {
|
|
480
496
|
...(parsed.aliases === undefined ? {} : { aliases: parsed.aliases }),
|
|
@@ -514,6 +530,552 @@ async function updateProject(args, store, options) {
|
|
|
514
530
|
return updated;
|
|
515
531
|
});
|
|
516
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* Lifecycle commands (reset/replace/retire/delete) are Operator authority:
|
|
535
|
+
* they discard commits, replace checkouts, or remove catalog records, so a
|
|
536
|
+
* managed Task Session may never drive them. A plain terminal is the human
|
|
537
|
+
* Operator; a managed global Session must be the Operator role.
|
|
538
|
+
*/
|
|
539
|
+
function assertProjectOperator(options, action) {
|
|
540
|
+
const actor = projectActor(options.environment);
|
|
541
|
+
if (actor === "agent") {
|
|
542
|
+
throw usageError(`Project lifecycle commands are Operator authority; a managed Task Session cannot ${action} a Project. `
|
|
543
|
+
+ "Run this command from an Operator or user terminal.");
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Fail-closed gate for the destructive lifecycle commands: an active Task
|
|
548
|
+
* binding means the canonical checkout and catalog record are in active use,
|
|
549
|
+
* so reset/replace/retire refuse until the Task is settled. Historical
|
|
550
|
+
* (completed/retired/archived) bindings are reported but never block — their
|
|
551
|
+
* evidence lives in the Task records themselves.
|
|
552
|
+
*/
|
|
553
|
+
function assertNoActiveTaskBinding(store, project, action) {
|
|
554
|
+
const references = store.summarizeProjectReferences(project.id);
|
|
555
|
+
if (references.activeTaskIds.length > 0) {
|
|
556
|
+
const delivery = [
|
|
557
|
+
...references.unresolvedWorkItemRefs,
|
|
558
|
+
...references.activeRunRefs,
|
|
559
|
+
...references.unresolvedIntegrationRefs
|
|
560
|
+
];
|
|
561
|
+
throw usageError(`Project cannot be ${action} while an active Task binds it: ${project.id}. `
|
|
562
|
+
+ `Active Tasks: ${references.activeTaskIds.join(", ")}.`
|
|
563
|
+
+ (delivery.length === 0 ? "" : ` Unresolved delivery: ${delivery.join(", ")}.`)
|
|
564
|
+
+ " Complete, retire, or archive those Tasks first.");
|
|
565
|
+
}
|
|
566
|
+
return references;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Controlled reset of a canonical Project checkout to its verified remote
|
|
570
|
+
* baseline. Handles the local/remote divergence that `project refresh`
|
|
571
|
+
* (clean fast-forward only) refuses: with `--discard-local` the checkout is
|
|
572
|
+
* hard-reset to the fetched remote commit, explicitly discarding the local
|
|
573
|
+
* commits listed in the refusal. Every destructive path is fail-closed:
|
|
574
|
+
* Operator authority, no active Task binding, clean checkout on its stable
|
|
575
|
+
* branch, reachable verified remote, and the per-Project maintenance fence.
|
|
576
|
+
*/
|
|
577
|
+
async function resetProject(args, store, options) {
|
|
578
|
+
const usage = "Project reset usage: yui project reset <project> [--discard-local].";
|
|
579
|
+
const parsed = parseResetArguments(args, usage);
|
|
580
|
+
const project = requireProject(store, parsed.project);
|
|
581
|
+
assertProjectOperator(options, "reset");
|
|
582
|
+
assertProjectActive(project, "reset");
|
|
583
|
+
if (project.remoteUrl === undefined) {
|
|
584
|
+
throw usageError(`Project reset requires a remote URL: ${project.id}.`);
|
|
585
|
+
}
|
|
586
|
+
if (project.stableBranch !== project.developmentBranch) {
|
|
587
|
+
throw usageError(`Project reset requires matching stable and development branches: ${project.id}.`);
|
|
588
|
+
}
|
|
589
|
+
const git = options.git ?? new NodeGitWorkspace();
|
|
590
|
+
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
591
|
+
try {
|
|
592
|
+
// Re-read under the fence so a concurrent catalog change can never drive
|
|
593
|
+
// a destructive Git effect from a stale snapshot.
|
|
594
|
+
const current = requireProject(store, project.id);
|
|
595
|
+
assertProjectActive(current, "reset");
|
|
596
|
+
if (current.path !== project.path
|
|
597
|
+
|| current.remoteUrl !== project.remoteUrl
|
|
598
|
+
|| current.stableBranch !== project.stableBranch
|
|
599
|
+
|| current.developmentBranch !== project.developmentBranch) {
|
|
600
|
+
throw new Error(`Project changed while resetting: ${project.id}.`);
|
|
601
|
+
}
|
|
602
|
+
assertNoActiveTaskBinding(store, current, "reset");
|
|
603
|
+
const head = await git.inspect(current.path, "HEAD");
|
|
604
|
+
if (!await git.isClean(head.root)) {
|
|
605
|
+
throw usageError(`Project checkout must be clean before reset: ${current.id}. `
|
|
606
|
+
+ "Commit, stash, or discard uncommitted changes first.");
|
|
607
|
+
}
|
|
608
|
+
const branch = await git.headRef(head.root);
|
|
609
|
+
if (branch !== current.stableBranch) {
|
|
610
|
+
const found = branch === "HEAD" ? "detached HEAD" : branch;
|
|
611
|
+
throw usageError(`Project checkout must be on its stable branch ${current.stableBranch} before reset (found ${found}).`);
|
|
612
|
+
}
|
|
613
|
+
// Fetch and verify the advertised remote baseline. An unreachable remote
|
|
614
|
+
// or a ref that moves mid-fetch fails closed here, before any local effect.
|
|
615
|
+
const remote = await git.resolveRemoteBaseline({
|
|
616
|
+
repositoryPath: head.root,
|
|
617
|
+
remoteUrl: current.remoteUrl,
|
|
618
|
+
developmentRef: current.stableBranch
|
|
619
|
+
});
|
|
620
|
+
const localCommit = head.baseCommit.toLowerCase();
|
|
621
|
+
const remoteCommit = remote.commit.toLowerCase();
|
|
622
|
+
if (localCommit === remoteCommit) {
|
|
623
|
+
return {
|
|
624
|
+
output: `Project ${current.id} is already at the remote baseline ${remoteCommit}.\n`,
|
|
625
|
+
data: {
|
|
626
|
+
project: current,
|
|
627
|
+
fromCommit: localCommit,
|
|
628
|
+
toCommit: remoteCommit,
|
|
629
|
+
changed: false,
|
|
630
|
+
discarded: []
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const isAncestor = await git.isAncestor(head.root, localCommit, remoteCommit);
|
|
635
|
+
if (!isAncestor) {
|
|
636
|
+
const discarded = await git.listCommitsBetween({
|
|
637
|
+
repositoryPath: head.root,
|
|
638
|
+
baseCommit: remoteCommit,
|
|
639
|
+
headCommit: localCommit
|
|
640
|
+
});
|
|
641
|
+
if (!parsed.discardLocal) {
|
|
642
|
+
throw usageError(`Project ${current.id} has diverged from ${current.remoteUrl}: local ${localCommit} `
|
|
643
|
+
+ `is not an ancestor of remote ${remoteCommit}.\n`
|
|
644
|
+
+ "Local commits that would be discarded:\n"
|
|
645
|
+
+ discarded.map((entry) => ` ${entry}`).join("\n")
|
|
646
|
+
+ "\nRe-run with --discard-local to hard-reset the checkout to the remote baseline.");
|
|
647
|
+
}
|
|
648
|
+
await git.resetToCommit({
|
|
649
|
+
repositoryPath: head.root,
|
|
650
|
+
expectedHead: localCommit,
|
|
651
|
+
targetCommit: remoteCommit
|
|
652
|
+
});
|
|
653
|
+
return {
|
|
654
|
+
output: `Reset project ${current.id}: ${localCommit} -> ${remoteCommit} `
|
|
655
|
+
+ `(discarded ${discarded.length} local commit(s)).\n`,
|
|
656
|
+
data: {
|
|
657
|
+
project: current,
|
|
658
|
+
fromCommit: localCommit,
|
|
659
|
+
toCommit: remoteCommit,
|
|
660
|
+
changed: true,
|
|
661
|
+
discarded
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
// Behind only: the verified reset is a clean fast-forward.
|
|
666
|
+
await git.resetToCommit({
|
|
667
|
+
repositoryPath: head.root,
|
|
668
|
+
expectedHead: localCommit,
|
|
669
|
+
targetCommit: remoteCommit
|
|
670
|
+
});
|
|
671
|
+
return {
|
|
672
|
+
output: `Reset project ${current.id}: ${localCommit} -> ${remoteCommit} (fast-forward).\n`,
|
|
673
|
+
data: {
|
|
674
|
+
project: current,
|
|
675
|
+
fromCommit: localCommit,
|
|
676
|
+
toCommit: remoteCommit,
|
|
677
|
+
changed: true,
|
|
678
|
+
discarded: []
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
releaseMaintenance();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Replace a Home-managed canonical checkout with a fresh clone from its
|
|
688
|
+
* remote, preserving the catalog binding and Yui-local refs. For checkouts
|
|
689
|
+
* whose divergence or corruption is too severe for `project reset`. The
|
|
690
|
+
* discard is explicit (`--discard-local`), the remote is verified before the
|
|
691
|
+
* swap, and Task/Integration worktrees are protected by a linked-worktree
|
|
692
|
+
* gate. The swap itself is recoverable: the previous checkout is parked at a
|
|
693
|
+
* backup path and restored on any failure, and a crash mid-swap is healed on
|
|
694
|
+
* the next run (a crash before the swap leaves only a removable staging
|
|
695
|
+
* clone).
|
|
696
|
+
*/
|
|
697
|
+
async function replaceProject(args, store, options) {
|
|
698
|
+
const usage = "Project replace usage: yui project replace <project> --discard-local.";
|
|
699
|
+
const parsed = parseReplaceArguments(args, usage);
|
|
700
|
+
const project = requireProject(store, parsed.project);
|
|
701
|
+
assertProjectOperator(options, "replace");
|
|
702
|
+
assertProjectActive(project, "replace");
|
|
703
|
+
if (project.ownership !== "managed") {
|
|
704
|
+
throw usageError(`Project replace only applies to Home-managed checkouts: ${project.id}. `
|
|
705
|
+
+ "External checkouts are user-owned; re-register or repair them with `yui project update`/`yui project add`.");
|
|
706
|
+
}
|
|
707
|
+
if (project.remoteUrl === undefined) {
|
|
708
|
+
throw usageError(`Project replace requires a remote URL: ${project.id}.`);
|
|
709
|
+
}
|
|
710
|
+
if (!parsed.discardLocal) {
|
|
711
|
+
throw usageError(`Project replace discards the current checkout and re-clones from ${project.remoteUrl}. `
|
|
712
|
+
+ "Re-run with --discard-local to acknowledge that the checkout and any uncommitted state will be discarded.");
|
|
713
|
+
}
|
|
714
|
+
const git = options.git ?? new NodeGitWorkspace();
|
|
715
|
+
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
716
|
+
try {
|
|
717
|
+
const current = requireProject(store, project.id);
|
|
718
|
+
assertProjectActive(current, "replace");
|
|
719
|
+
if (current.path !== project.path
|
|
720
|
+
|| current.remoteUrl !== project.remoteUrl
|
|
721
|
+
|| current.ownership !== project.ownership
|
|
722
|
+
|| current.stableBranch !== project.stableBranch
|
|
723
|
+
|| current.developmentBranch !== project.developmentBranch) {
|
|
724
|
+
throw new Error(`Project changed while replacing: ${project.id}.`);
|
|
725
|
+
}
|
|
726
|
+
assertNoActiveTaskBinding(store, current, "replace");
|
|
727
|
+
const backup = join(store.rootDirectory(), "projects", `.replace-backup-${current.id}`);
|
|
728
|
+
// Heal a crashed earlier swap before any new destructive step, so the
|
|
729
|
+
// prechecks below always see a valid checkout.
|
|
730
|
+
await healCheckoutSwap({ currentPath: current.path, backupPath: backup });
|
|
731
|
+
// A linked worktree (Task/Integration workspace) shares this repository's
|
|
732
|
+
// object store and would be broken by a wholesale checkout replacement.
|
|
733
|
+
const worktrees = await git.listWorktrees(current.path);
|
|
734
|
+
const linked = worktrees.filter((path) => path !== current.path);
|
|
735
|
+
if (linked.length > 0) {
|
|
736
|
+
throw usageError(`Project checkout has linked worktrees that would break: ${current.id}.\n`
|
|
737
|
+
+ linked.map((path) => ` ${path}`).join("\n")
|
|
738
|
+
+ "\nClean up Task/Integration workspaces first (e.g. `yui task work cleanup`, `yui task integration cleanup`).");
|
|
739
|
+
}
|
|
740
|
+
if (!await git.isClean(current.path)) {
|
|
741
|
+
throw usageError(`Project checkout must be clean before replace: ${current.id}. `
|
|
742
|
+
+ "Commit, stash, or discard uncommitted changes first.");
|
|
743
|
+
}
|
|
744
|
+
const staging = join(store.rootDirectory(), "projects", `.replace-${current.id}`);
|
|
745
|
+
if (existsSync(staging)) {
|
|
746
|
+
// A crashed earlier attempt can only leave its own staging clone behind.
|
|
747
|
+
await rm(staging, { recursive: true, force: true });
|
|
748
|
+
}
|
|
749
|
+
let prepared = false;
|
|
750
|
+
try {
|
|
751
|
+
const head = await git.clone({
|
|
752
|
+
remoteUrl: current.remoteUrl,
|
|
753
|
+
destination: staging,
|
|
754
|
+
...(current.stableBranch === "HEAD" ? {} : { branch: current.stableBranch })
|
|
755
|
+
});
|
|
756
|
+
prepared = true;
|
|
757
|
+
const stable = current.stableBranch === "HEAD"
|
|
758
|
+
? head
|
|
759
|
+
: await git.inspect(staging, current.stableBranch);
|
|
760
|
+
if (head.baseCommit !== stable.baseCommit) {
|
|
761
|
+
throw new Error(`Project checkout is not on its stable ref: ${current.stableBranch}.`);
|
|
762
|
+
}
|
|
763
|
+
if (current.developmentBranch !== current.stableBranch) {
|
|
764
|
+
await git.ensureLocalBranch(staging, current.developmentBranch);
|
|
765
|
+
}
|
|
766
|
+
await assertRemoteBranchesVerified(git, staging, current.remoteUrl, [
|
|
767
|
+
{ ref: current.stableBranch, localCommit: head.baseCommit },
|
|
768
|
+
...(current.developmentBranch === current.stableBranch
|
|
769
|
+
? []
|
|
770
|
+
: [{
|
|
771
|
+
ref: current.developmentBranch,
|
|
772
|
+
localCommit: (await git.inspect(staging, current.developmentBranch)).baseCommit
|
|
773
|
+
}])
|
|
774
|
+
]);
|
|
775
|
+
// Preserve Yui-local refs (Task branches, archive refs) so historical
|
|
776
|
+
// evidence keeps resolving after the checkout is replaced.
|
|
777
|
+
const copyRefs = git.copyRefs;
|
|
778
|
+
if (typeof copyRefs !== "function") {
|
|
779
|
+
throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
|
|
780
|
+
}
|
|
781
|
+
await copyRefs.call(git, {
|
|
782
|
+
sourceRepositoryPath: current.path,
|
|
783
|
+
destinationRepositoryPath: staging,
|
|
784
|
+
patterns: ["refs/heads/yui/", "refs/yui/archive/"]
|
|
785
|
+
});
|
|
786
|
+
const oldHead = (await git.inspect(current.path, "HEAD")).baseCommit;
|
|
787
|
+
// Swap the checkout on disk without ever leaving the registered path
|
|
788
|
+
// without a repository: the previous checkout is parked at the backup
|
|
789
|
+
// path first and restored on any failure. The backup is removed only
|
|
790
|
+
// after the catalog transaction commits, so a crash in between stays
|
|
791
|
+
// recoverable (healed at the top of the next run).
|
|
792
|
+
await swapManagedCheckout({ currentPath: current.path, stagingPath: staging, backupPath: backup });
|
|
793
|
+
let switched;
|
|
794
|
+
try {
|
|
795
|
+
switched = store.transaction((tx) => {
|
|
796
|
+
const latest = requireProject(tx, current.id);
|
|
797
|
+
if (latest.path !== current.path || latest.ownership !== current.ownership) {
|
|
798
|
+
throw new Error(`Project changed while replacing: ${current.id}.`);
|
|
799
|
+
}
|
|
800
|
+
const next = validateProject({
|
|
801
|
+
...latest,
|
|
802
|
+
updatedAt: (options.now ?? (() => new Date()))().toISOString()
|
|
803
|
+
});
|
|
804
|
+
tx.saveProject(next);
|
|
805
|
+
return next;
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
try {
|
|
810
|
+
await restoreCheckoutSwap({ currentPath: current.path, backupPath: backup });
|
|
811
|
+
}
|
|
812
|
+
catch (rollbackError) {
|
|
813
|
+
throw new Error(`Project replace failed and checkout rollback was incomplete: ${messageOf(error)}; `
|
|
814
|
+
+ `rollback failed: ${messageOf(rollbackError)}. `
|
|
815
|
+
+ `The previous checkout remains parked at ${backup}.`);
|
|
816
|
+
}
|
|
817
|
+
throw error;
|
|
818
|
+
}
|
|
819
|
+
let backupLeftover = false;
|
|
820
|
+
try {
|
|
821
|
+
await rm(backup, { recursive: true, force: true });
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
backupLeftover = true;
|
|
825
|
+
}
|
|
826
|
+
const newHead = (await git.inspect(current.path, "HEAD")).baseCommit;
|
|
827
|
+
return {
|
|
828
|
+
output: `Replaced project ${current.id} checkout: ${oldHead} -> ${newHead} `
|
|
829
|
+
+ `(re-cloned from ${current.remoteUrl}).\n`
|
|
830
|
+
+ (backupLeftover
|
|
831
|
+
? `The previous checkout could not be removed; delete it manually: ${backup}.\n`
|
|
832
|
+
: ""),
|
|
833
|
+
data: { project: switched, fromCommit: oldHead, toCommit: newHead }
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (prepared && existsSync(staging)) {
|
|
838
|
+
await rm(staging, { recursive: true, force: true });
|
|
839
|
+
}
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
finally {
|
|
844
|
+
releaseMaintenance();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Auditable soft deprecation. The catalog record, checkout, and every
|
|
849
|
+
* historical Task/Run/Review/Integration/Publication reference are retained;
|
|
850
|
+
* the Project can no longer be bound to new work or maintained. Hard removal
|
|
851
|
+
* is a separate `project delete` decision.
|
|
852
|
+
*/
|
|
853
|
+
function retireProjectCommand(args, store, options) {
|
|
854
|
+
const usage = "Project retire usage: yui project retire <project> --reason <text>.";
|
|
855
|
+
const parsed = parseRetireArguments(args, usage);
|
|
856
|
+
const project = requireProject(store, parsed.project);
|
|
857
|
+
assertProjectOperator(options, "retire");
|
|
858
|
+
assertProjectActive(project, "retire");
|
|
859
|
+
const actor = projectActor(options.environment);
|
|
860
|
+
const retiredBy = actor === "operator" ? "operator" : "user";
|
|
861
|
+
const now = (options.now ?? (() => new Date()))();
|
|
862
|
+
const retired = store.transaction((tx) => {
|
|
863
|
+
const latest = requireProject(tx, project.id);
|
|
864
|
+
assertProjectActive(latest, "retire");
|
|
865
|
+
assertNoActiveTaskBinding(tx, latest, "retire");
|
|
866
|
+
const next = retireProject(latest, parsed.reason, retiredBy, now);
|
|
867
|
+
tx.saveProject(next);
|
|
868
|
+
return { project: next, references: tx.summarizeProjectReferences(next.id) };
|
|
869
|
+
});
|
|
870
|
+
const lines = [
|
|
871
|
+
`Retired project ${retired.project.id} (${retired.project.name})`,
|
|
872
|
+
`Reason: ${parsed.reason}`,
|
|
873
|
+
`Retired by: ${retiredBy} at ${retired.project.retirement?.retiredAt ?? "?"}`,
|
|
874
|
+
"The catalog record, checkout, and historical Task/Run/Review/Integration/Publication evidence are retained.",
|
|
875
|
+
...(retired.references.boundTaskIds.length === 0
|
|
876
|
+
? []
|
|
877
|
+
: [`Historical Task bindings retained: ${retired.references.boundTaskIds.join(", ")}.`]),
|
|
878
|
+
"Use `yui project delete` to remove the catalog record once no Task references it."
|
|
879
|
+
];
|
|
880
|
+
return {
|
|
881
|
+
output: lines.join("\n").concat("\n"),
|
|
882
|
+
data: { project: retired.project, references: retired.references }
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Hard removal of a retired Project catalog record, optionally including its
|
|
887
|
+
* Home-managed checkout. Deletion is deliberately two-phase (retire first)
|
|
888
|
+
* and doubly acknowledged (--confirm with the exact Project id), and it fails
|
|
889
|
+
* closed while any Task record references the Project so historical evidence
|
|
890
|
+
* stays resolvable. With `--checkout`, every fail-closed precheck (linked
|
|
891
|
+
* worktrees, dirty checkout) runs before any destruction, and the checkout is
|
|
892
|
+
* moved to a tombstone before the catalog record is removed; any failure
|
|
893
|
+
* restores it, so catalog and checkout never disagree unrecoverably.
|
|
894
|
+
*/
|
|
895
|
+
async function deleteProjectCommand(args, store, options) {
|
|
896
|
+
const usage = "Project delete usage: yui project delete <project> [--checkout] --confirm <project-id>.";
|
|
897
|
+
const parsed = parseDeleteArguments(args, usage);
|
|
898
|
+
const project = requireProject(store, parsed.project);
|
|
899
|
+
assertProjectOperator(options, "delete");
|
|
900
|
+
if (project.status !== "retired") {
|
|
901
|
+
throw usageError(`Project must be retired before it can be deleted: ${project.id}. `
|
|
902
|
+
+ "Use `yui project retire <project> --reason <text>` first; retirement keeps the audit trail.");
|
|
903
|
+
}
|
|
904
|
+
if (parsed.confirm !== project.id) {
|
|
905
|
+
throw usageError(`Project delete requires --confirm ${project.id} to acknowledge removal of the catalog record`
|
|
906
|
+
+ `${parsed.checkout ? " and the checkout" : ""}.`);
|
|
907
|
+
}
|
|
908
|
+
if (parsed.checkout && project.ownership !== "managed") {
|
|
909
|
+
throw usageError(`Project delete --checkout only applies to Home-managed checkouts: ${project.id}. `
|
|
910
|
+
+ "External checkouts are user-owned; remove them manually after deleting the binding.");
|
|
911
|
+
}
|
|
912
|
+
if (!parsed.checkout) {
|
|
913
|
+
const removed = removeRetiredProjectRecord(store, project.id);
|
|
914
|
+
return {
|
|
915
|
+
output: `Deleted project ${removed.id} (catalog record; checkout retained at ${removed.path}).\n`,
|
|
916
|
+
data: { project: removed, checkoutRemoved: false }
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
// --checkout: prechecks first, then a rename/tombstone two-phase flow. The
|
|
920
|
+
// checkout moves to a tombstone before the catalog record is removed, and
|
|
921
|
+
// every failure restores it, so the catalog never loses its recoverable
|
|
922
|
+
// entry while a live checkout (or its failure) is still in play.
|
|
923
|
+
const tombstone = join(store.rootDirectory(), "projects", `.delete-${project.id}`);
|
|
924
|
+
const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
|
|
925
|
+
try {
|
|
926
|
+
// Heal a crashed earlier attempt before the prechecks run.
|
|
927
|
+
await healCheckoutSwap({ currentPath: project.path, backupPath: tombstone });
|
|
928
|
+
if (existsSync(project.path)) {
|
|
929
|
+
const git = options.git ?? new NodeGitWorkspace();
|
|
930
|
+
const worktrees = await git.listWorktrees(project.path);
|
|
931
|
+
const linked = worktrees.filter((path) => path !== project.path);
|
|
932
|
+
if (linked.length > 0) {
|
|
933
|
+
throw usageError(`Project checkout has linked worktrees that would break: ${project.id}.\n`
|
|
934
|
+
+ linked.map((path) => ` ${path}`).join("\n")
|
|
935
|
+
+ "\nClean up Task/Integration workspaces first (e.g. `yui task work cleanup`, `yui task integration cleanup`).");
|
|
936
|
+
}
|
|
937
|
+
if (!await git.isClean(project.path)) {
|
|
938
|
+
throw usageError(`Project checkout must be clean before delete: ${project.id}. `
|
|
939
|
+
+ "Commit, stash, or discard uncommitted changes first, or delete the catalog record only without --checkout.");
|
|
940
|
+
}
|
|
941
|
+
// Phase 1: park the checkout. The catalog record still exists, so
|
|
942
|
+
// phase 2 must complete or roll the rename back.
|
|
943
|
+
await rename(project.path, tombstone);
|
|
944
|
+
}
|
|
945
|
+
// Phase 2: remove the catalog record, restoring the checkout on failure.
|
|
946
|
+
let removed;
|
|
947
|
+
try {
|
|
948
|
+
removed = removeRetiredProjectRecord(store, project.id);
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
if (existsSync(tombstone) && !existsSync(project.path)) {
|
|
952
|
+
try {
|
|
953
|
+
await rename(tombstone, project.path);
|
|
954
|
+
}
|
|
955
|
+
catch (rollbackError) {
|
|
956
|
+
throw new Error(`Project delete failed and checkout rollback was incomplete: ${messageOf(error)}; `
|
|
957
|
+
+ `rollback failed: ${messageOf(rollbackError)}. `
|
|
958
|
+
+ `The checkout remains parked at ${tombstone}.`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
// Phase 3: the catalog record is gone, so the tombstone is unreferenced
|
|
964
|
+
// garbage. Removal is best-effort; a leftover is reported, never silent.
|
|
965
|
+
let tombstoneLeftover = false;
|
|
966
|
+
if (existsSync(tombstone)) {
|
|
967
|
+
try {
|
|
968
|
+
await rm(tombstone, { recursive: true, force: true });
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
tombstoneLeftover = true;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
output: `Deleted project ${removed.id} and its checkout at ${removed.path}.\n`
|
|
976
|
+
+ (tombstoneLeftover
|
|
977
|
+
? `The checkout tombstone could not be removed; delete it manually: ${tombstone}.\n`
|
|
978
|
+
: ""),
|
|
979
|
+
data: { project: removed, checkoutRemoved: true }
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
releaseMaintenance();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* The catalog side of `project delete`: re-read under a transaction, re-assert
|
|
988
|
+
* the retired state and the absence of any Task reference, then remove the
|
|
989
|
+
* record. Fails closed with the record intact on every refusal.
|
|
990
|
+
*/
|
|
991
|
+
function removeRetiredProjectRecord(store, projectId) {
|
|
992
|
+
return store.transaction((tx) => {
|
|
993
|
+
const latest = requireProject(tx, projectId);
|
|
994
|
+
if (latest.status !== "retired") {
|
|
995
|
+
throw new Error(`Project changed while deleting: ${projectId}.`);
|
|
996
|
+
}
|
|
997
|
+
const references = tx.summarizeProjectReferences(latest.id);
|
|
998
|
+
if (references.boundTaskIds.length > 0) {
|
|
999
|
+
throw usageError(`Project cannot be deleted while Task records reference it: ${latest.id}. `
|
|
1000
|
+
+ `Bound Tasks: ${references.boundTaskIds.join(", ")}. `
|
|
1001
|
+
+ "Historical Task/Run/Review/Integration/Publication evidence must stay resolvable; keep the Project retired instead.");
|
|
1002
|
+
}
|
|
1003
|
+
if (!tx.removeProject(latest.id)) {
|
|
1004
|
+
throw new Error(`Project was already removed: ${latest.id}.`);
|
|
1005
|
+
}
|
|
1006
|
+
return latest;
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
function parseResetArguments(args, usage) {
|
|
1010
|
+
const positionals = [];
|
|
1011
|
+
let discardLocal = false;
|
|
1012
|
+
for (const value of args) {
|
|
1013
|
+
if (value === "--discard-local") {
|
|
1014
|
+
discardLocal = true;
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
if (value.startsWith("--"))
|
|
1018
|
+
throw usageError(`Unknown option: ${value}. ${usage}`);
|
|
1019
|
+
positionals.push(value);
|
|
1020
|
+
}
|
|
1021
|
+
if (positionals.length !== 1)
|
|
1022
|
+
throw usageError(usage);
|
|
1023
|
+
return { project: requireText(positionals[0], "Project reference"), discardLocal };
|
|
1024
|
+
}
|
|
1025
|
+
function parseReplaceArguments(args, usage) {
|
|
1026
|
+
return parseResetArguments(args, usage);
|
|
1027
|
+
}
|
|
1028
|
+
function parseRetireArguments(args, usage) {
|
|
1029
|
+
const positionals = [];
|
|
1030
|
+
let reason;
|
|
1031
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1032
|
+
const value = args[index];
|
|
1033
|
+
if (value === "--reason") {
|
|
1034
|
+
const next = args[index + 1];
|
|
1035
|
+
if (next === undefined || next.startsWith("--"))
|
|
1036
|
+
throw usageError(`--reason is required. ${usage}`);
|
|
1037
|
+
reason = requireText(next, "--reason");
|
|
1038
|
+
index += 1;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (value.startsWith("--"))
|
|
1042
|
+
throw usageError(`Unknown option: ${value}. ${usage}`);
|
|
1043
|
+
positionals.push(value);
|
|
1044
|
+
}
|
|
1045
|
+
if (positionals.length !== 1)
|
|
1046
|
+
throw usageError(usage);
|
|
1047
|
+
if (reason === undefined)
|
|
1048
|
+
throw usageError(`--reason is required. ${usage}`);
|
|
1049
|
+
return { project: requireText(positionals[0], "Project reference"), reason };
|
|
1050
|
+
}
|
|
1051
|
+
function parseDeleteArguments(args, usage) {
|
|
1052
|
+
const positionals = [];
|
|
1053
|
+
let checkout = false;
|
|
1054
|
+
let confirm;
|
|
1055
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1056
|
+
const value = args[index];
|
|
1057
|
+
if (value === "--checkout") {
|
|
1058
|
+
checkout = true;
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (value === "--confirm") {
|
|
1062
|
+
const next = args[index + 1];
|
|
1063
|
+
if (next === undefined || next.startsWith("--"))
|
|
1064
|
+
throw usageError(`--confirm is required. ${usage}`);
|
|
1065
|
+
confirm = requireText(next, "--confirm");
|
|
1066
|
+
index += 1;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
if (value.startsWith("--"))
|
|
1070
|
+
throw usageError(`Unknown option: ${value}. ${usage}`);
|
|
1071
|
+
positionals.push(value);
|
|
1072
|
+
}
|
|
1073
|
+
if (positionals.length !== 1)
|
|
1074
|
+
throw usageError(usage);
|
|
1075
|
+
if (confirm === undefined)
|
|
1076
|
+
throw usageError(`--confirm is required. ${usage}`);
|
|
1077
|
+
return { project: requireText(positionals[0], "Project reference"), checkout, confirm };
|
|
1078
|
+
}
|
|
517
1079
|
function renderAddedProject(created) {
|
|
518
1080
|
return [
|
|
519
1081
|
`Added project ${created.id}`,
|
|
@@ -559,6 +1121,7 @@ function listProjects(args, store) {
|
|
|
559
1121
|
return `${renderTable("Projects", [
|
|
560
1122
|
{ header: "Project", minWidth: 8, maxWidth: 24 },
|
|
561
1123
|
{ header: "Name", minWidth: 4, maxWidth: 28 },
|
|
1124
|
+
{ header: "Status", minWidth: 6, maxWidth: 8 },
|
|
562
1125
|
{ header: "Ownership", minWidth: 9, maxWidth: 10 },
|
|
563
1126
|
{ header: "Path", minWidth: 8, maxWidth: 64 },
|
|
564
1127
|
{ header: "Stable", minWidth: 6, maxWidth: 24 },
|
|
@@ -566,6 +1129,7 @@ function listProjects(args, store) {
|
|
|
566
1129
|
], projects.map((project) => [
|
|
567
1130
|
project.id,
|
|
568
1131
|
project.name,
|
|
1132
|
+
project.status,
|
|
569
1133
|
project.ownership,
|
|
570
1134
|
project.path,
|
|
571
1135
|
project.stableBranch,
|
|
@@ -579,6 +1143,13 @@ function showProject(args, store) {
|
|
|
579
1143
|
return [
|
|
580
1144
|
`Project: ${project.id}`,
|
|
581
1145
|
`Name: ${project.name}`,
|
|
1146
|
+
`Status: ${project.status}`,
|
|
1147
|
+
...(project.retirement === undefined
|
|
1148
|
+
? []
|
|
1149
|
+
: [
|
|
1150
|
+
`Retired by: ${project.retirement.retiredBy} at ${project.retirement.retiredAt}`,
|
|
1151
|
+
`Retirement reason: ${project.retirement.reason}`
|
|
1152
|
+
]),
|
|
582
1153
|
`Ownership: ${project.ownership}`,
|
|
583
1154
|
`Aliases: ${project.aliases.length === 0 ? "-" : project.aliases.join(", ")}`,
|
|
584
1155
|
`Path: ${project.path}`,
|
|
@@ -599,6 +1170,7 @@ function projectKnowledge(args, store, options) {
|
|
|
599
1170
|
assertKnowledgeOperator(options, "add");
|
|
600
1171
|
const added = store.transaction((tx) => {
|
|
601
1172
|
const project = requireProject(tx, parsed.positionals[0]);
|
|
1173
|
+
assertProjectActive(project, "add project knowledge");
|
|
602
1174
|
const id = nextKnowledgeId(project);
|
|
603
1175
|
tx.saveProject(addProjectKnowledge(project, id, parsed.positionals[1], parsed.value, (options.now ?? (() => new Date()))()));
|
|
604
1176
|
return { id, projectId: project.id };
|
|
@@ -685,6 +1257,7 @@ function projectKnowledge(args, store, options) {
|
|
|
685
1257
|
assertKnowledgeOperator(options, "retire");
|
|
686
1258
|
const updated = store.transaction((tx) => {
|
|
687
1259
|
const project = requireProject(tx, rest[0]);
|
|
1260
|
+
assertProjectActive(project, "retire project knowledge");
|
|
688
1261
|
const next = retireProjectKnowledge(project, rest[1], (options.now ?? (() => new Date()))());
|
|
689
1262
|
tx.saveProject(next);
|
|
690
1263
|
return next;
|
|
@@ -823,6 +1396,7 @@ function proposeKnowledge(args, store, options) {
|
|
|
823
1396
|
const now = (options.now ?? (() => new Date()))();
|
|
824
1397
|
const result = store.transaction((tx) => {
|
|
825
1398
|
const project = requireProject(tx, parsed.project);
|
|
1399
|
+
assertProjectActive(project, "propose project knowledge");
|
|
826
1400
|
const source = requireProposalEvidence(tx, parsed);
|
|
827
1401
|
const fingerprint = knowledgeProposalFingerprint({
|
|
828
1402
|
projectId: project.id,
|
|
@@ -1056,6 +1630,7 @@ function acceptKnowledgeProposal(args, store, options) {
|
|
|
1056
1630
|
const now = (options.now ?? (() => new Date()))();
|
|
1057
1631
|
const accepted = store.transaction((tx) => {
|
|
1058
1632
|
const project = requireProject(tx, parsed.project);
|
|
1633
|
+
assertProjectActive(project, "accept project knowledge");
|
|
1059
1634
|
const proposal = findKnowledgeProposal(project, parsed.proposal);
|
|
1060
1635
|
if (proposal === null) {
|
|
1061
1636
|
throw usageError(`Knowledge proposal not found: ${parsed.proposal}.`);
|
|
@@ -1143,6 +1718,7 @@ function rejectKnowledgeProposal(args, store, options) {
|
|
|
1143
1718
|
const decidedBy = actor === "operator" ? "operator" : "user";
|
|
1144
1719
|
const rejected = store.transaction((tx) => {
|
|
1145
1720
|
const project = requireProject(tx, parsed.positionals[0]);
|
|
1721
|
+
assertProjectActive(project, "reject project knowledge");
|
|
1146
1722
|
const proposal = findKnowledgeProposal(project, parsed.positionals[1]);
|
|
1147
1723
|
if (proposal === null) {
|
|
1148
1724
|
throw usageError(`Knowledge proposal not found: ${parsed.positionals[1]}.`);
|
|
@@ -1412,3 +1988,6 @@ function requireText(value, label) {
|
|
|
1412
1988
|
}
|
|
1413
1989
|
return normalized;
|
|
1414
1990
|
}
|
|
1991
|
+
function messageOf(error) {
|
|
1992
|
+
return error instanceof Error ? error.message : String(error);
|
|
1993
|
+
}
|
|
@@ -40,7 +40,7 @@ import { activateTask, addTaskProjectBinding, archiveTask, completeTask, createT
|
|
|
40
40
|
import { resolveTaskRecordReference } from "../task/taskRecordReference.js";
|
|
41
41
|
import { TASK_COMPLETION_PUBLISHED_TREE_AUTHORIZED_EVENT } from "../task/publicationReference.js";
|
|
42
42
|
import { projectCompletionReadiness } from "../task/completionReadiness.js";
|
|
43
|
-
import { resolveProject } from "../repository/project.js";
|
|
43
|
+
import { assertProjectActive, resolveProject } from "../repository/project.js";
|
|
44
44
|
import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
|
|
45
45
|
import { currentWorkItemCandidate, currentWorkItemExecutionGroup, workItemExecutionGroupById, createWorkItem, planWorkItemExplorationStage, attachWorkItemExecutionGroup, updateWorkItemExecutionGroup, retireWorkItem, retryFailedWorkItem, submitWorkItemCandidate, updateWorkItemWriteProjects, updateWorkItemStatus } from "../workItem/workItem.js";
|
|
46
46
|
import { addExecutionLane, createExecutionGroup, queueExecutionLaneRetry, resolveExecutionGroup, restartExecutionLane, skipPendingExecutionLanes, updateExecutionLane, WORK_ITEM_EXECUTION_MODES } from "../execution/executionGroup.js";
|
|
@@ -385,6 +385,7 @@ function taskProjectCommand(args, store, options) {
|
|
|
385
385
|
const project = resolveProject(tx.listProjects(), parsed.positionals[1]);
|
|
386
386
|
if (project === null)
|
|
387
387
|
throw usageError(`Project not found: ${parsed.positionals[1]}.`);
|
|
388
|
+
assertProjectActive(project, "bind to a Task");
|
|
388
389
|
const next = addTaskProjectBinding(task, {
|
|
389
390
|
projectId: project.id,
|
|
390
391
|
directory: parsed.options.get("--directory") ?? project.name,
|
|
@@ -532,6 +533,7 @@ function parseTaskCreation(args, store) {
|
|
|
532
533
|
const project = resolveProject(store.listProjects(), reference);
|
|
533
534
|
if (project === null)
|
|
534
535
|
throw usageError(`Project not found: ${reference}.`);
|
|
536
|
+
assertProjectActive(project, "bind to a Task");
|
|
535
537
|
return project;
|
|
536
538
|
});
|
|
537
539
|
if (new Set(projects.map(({ id }) => id)).size !== projects.length) {
|
|
@@ -1857,6 +1859,7 @@ function createWork(args, store, options) {
|
|
|
1857
1859
|
const project = resolveProject(task.projectBindings.map(({ projectId }) => requireProject(tx, projectId)), reference);
|
|
1858
1860
|
if (project === null)
|
|
1859
1861
|
throw usageError(`Task Project not found: ${reference}.`);
|
|
1862
|
+
assertProjectActive(project, "scope a Work Item");
|
|
1860
1863
|
return project.id;
|
|
1861
1864
|
});
|
|
1862
1865
|
const baseRefs = parsed.baseRefs.map(({ project, baseRef }) => {
|
|
@@ -1914,6 +1917,7 @@ function updateWorkScope(args, store, options) {
|
|
|
1914
1917
|
const project = resolveProject(task.projectBindings.map(({ projectId }) => requireProject(tx, projectId)), reference);
|
|
1915
1918
|
if (project === null)
|
|
1916
1919
|
throw usageError(`Task Project not found: ${reference}.`);
|
|
1920
|
+
assertProjectActive(project, "scope a Work Item");
|
|
1917
1921
|
return project.id;
|
|
1918
1922
|
});
|
|
1919
1923
|
const requested = new Set(requestedProjectIds);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
2
|
-
import { resolveProject } from "../repository/project.js";
|
|
2
|
+
import { assertProjectActive, resolveProject } from "../repository/project.js";
|
|
3
3
|
import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
|
|
4
4
|
import { usageError } from "../errors/cliError.js";
|
|
5
5
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
@@ -114,6 +114,7 @@ async function start(args, store, home, now, options) {
|
|
|
114
114
|
: resolveProject(store.listProjects(), requestedProject);
|
|
115
115
|
if (project === null)
|
|
116
116
|
throw usageError(`Project not found: ${requestedProject ?? projectIds[0]}.`);
|
|
117
|
+
assertProjectActive(project, "start an Integration");
|
|
117
118
|
if (project.id !== projectIds[0]) {
|
|
118
119
|
throw usageError(`ChangeSets belong to another Project: ${projectIds[0]}.`);
|
|
119
120
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { rename as defaultRename, rm as defaultRm } from "node:fs/promises";
|
|
3
|
+
const realPorts = {
|
|
4
|
+
rename: (sourcePath, targetPath) => defaultRename(sourcePath, targetPath),
|
|
5
|
+
remove: (path) => defaultRm(path, { recursive: true, force: true })
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Heal the on-disk state of a crashed earlier swap before any new destructive
|
|
9
|
+
* step runs. A backup next to a live checkout is a leftover from a completed
|
|
10
|
+
* swap whose cleanup crashed and is removed; a backup without a live checkout
|
|
11
|
+
* means the crash happened mid-swap, so the previous checkout is restored
|
|
12
|
+
* intact. With no backup there is nothing to heal.
|
|
13
|
+
*/
|
|
14
|
+
export async function healCheckoutSwap(request, ports = realPorts) {
|
|
15
|
+
if (!existsSync(request.backupPath))
|
|
16
|
+
return;
|
|
17
|
+
if (existsSync(request.currentPath)) {
|
|
18
|
+
await ports.remove(request.backupPath);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await ports.rename(request.backupPath, request.currentPath);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Promote a prepared staging checkout into the registered path without ever
|
|
25
|
+
* leaving the registered path without a valid repository. The previous
|
|
26
|
+
* checkout is moved to the backup path first; if the promotion fails it is
|
|
27
|
+
* restored and the staging clone is removed. A crash between the two renames
|
|
28
|
+
* is healed by {@link healCheckoutSwap} on the next run.
|
|
29
|
+
*/
|
|
30
|
+
export async function swapManagedCheckout(request, ports = realPorts) {
|
|
31
|
+
await healCheckoutSwap(request, ports);
|
|
32
|
+
await ports.rename(request.currentPath, request.backupPath);
|
|
33
|
+
try {
|
|
34
|
+
await ports.rename(request.stagingPath, request.currentPath);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
try {
|
|
38
|
+
await ports.rename(request.backupPath, request.currentPath);
|
|
39
|
+
}
|
|
40
|
+
catch (rollbackError) {
|
|
41
|
+
throw new Error(`Checkout swap failed and rollback was incomplete: ${messageOf(error)}; `
|
|
42
|
+
+ `rollback failed: ${messageOf(rollbackError)}. `
|
|
43
|
+
+ `The previous checkout remains parked at ${request.backupPath}.`);
|
|
44
|
+
}
|
|
45
|
+
await ports.remove(request.stagingPath).catch(() => { });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Restore the parked previous checkout after a committed swap had to be undone
|
|
51
|
+
* (for example because the catalog transaction refused the change). The
|
|
52
|
+
* promoted clone is discarded first; if the restore fails the parked checkout
|
|
53
|
+
* stays recoverable through {@link healCheckoutSwap}.
|
|
54
|
+
*/
|
|
55
|
+
export async function restoreCheckoutSwap(request, ports = realPorts) {
|
|
56
|
+
await ports.remove(request.currentPath);
|
|
57
|
+
await ports.rename(request.backupPath, request.currentPath);
|
|
58
|
+
}
|
|
59
|
+
function messageOf(error) {
|
|
60
|
+
return error instanceof Error ? error.message : String(error);
|
|
61
|
+
}
|
|
@@ -694,6 +694,42 @@ export class NodeGitWorkspace {
|
|
|
694
694
|
throw new Error(`Git reset compensation failed for ${target}; Candidate materialization remains retained for diagnosis.`, { cause: error });
|
|
695
695
|
}
|
|
696
696
|
}
|
|
697
|
+
async resetToCommit(input) {
|
|
698
|
+
const root = await canonicalDirectory(input.repositoryPath, "Project");
|
|
699
|
+
const expectedHead = requireText(input.expectedHead, "Expected head");
|
|
700
|
+
const targetCommit = requireText(input.targetCommit, "Target commit");
|
|
701
|
+
if (!await this.isClean(root)) {
|
|
702
|
+
throw new Error(`Project checkout must be clean before reset: ${root}.`);
|
|
703
|
+
}
|
|
704
|
+
const currentHead = (await this.inspect(root, "HEAD")).baseCommit;
|
|
705
|
+
if (currentHead.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
706
|
+
throw new Error(`Project checkout changed before reset: ${root} (${currentHead}).`);
|
|
707
|
+
}
|
|
708
|
+
// Verify the target commit resolves before moving the branch.
|
|
709
|
+
await gitLine(["-C", root, "rev-parse", "--verify", "--end-of-options", `${targetCommit}^{commit}`]);
|
|
710
|
+
await git(["-C", root, "reset", "--hard", targetCommit]);
|
|
711
|
+
const restoredHead = (await this.inspect(root, "HEAD")).baseCommit;
|
|
712
|
+
if (restoredHead.toLowerCase() !== targetCommit.toLowerCase() || !await this.isClean(root)) {
|
|
713
|
+
throw new Error(`Project checkout did not reset to the exact target commit: ${root}.`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async listWorktrees(repositoryPath) {
|
|
717
|
+
const root = await canonicalDirectory(repositoryPath, "Project");
|
|
718
|
+
const output = await git(["-C", root, "worktree", "list", "--porcelain"]);
|
|
719
|
+
return output
|
|
720
|
+
.split(/\r?\n/u)
|
|
721
|
+
.filter((line) => line.startsWith("worktree "))
|
|
722
|
+
.map((line) => line.slice("worktree ".length).trim());
|
|
723
|
+
}
|
|
724
|
+
async listCommitsBetween(input) {
|
|
725
|
+
const root = await canonicalDirectory(input.repositoryPath, "Project");
|
|
726
|
+
const range = `${requireText(input.baseCommit, "Base commit")}..${requireText(input.headCommit, "Head commit")}`;
|
|
727
|
+
const output = await git(["-C", root, "log", "--oneline", range]);
|
|
728
|
+
return output
|
|
729
|
+
.split(/\r?\n/u)
|
|
730
|
+
.map((line) => line.trim())
|
|
731
|
+
.filter((line) => line.length > 0);
|
|
732
|
+
}
|
|
697
733
|
async #assertRefreshCheckout(repositoryPath, stableRef, expectedCommit) {
|
|
698
734
|
if (stableRef !== "HEAD") {
|
|
699
735
|
const currentBranch = await this.headRef(repositoryPath);
|
|
@@ -8,7 +8,7 @@ export function managedProjectPath(home, projectId) {
|
|
|
8
8
|
export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
9
9
|
const timestamp = now.toISOString();
|
|
10
10
|
return validateProject({
|
|
11
|
-
schemaVersion:
|
|
11
|
+
schemaVersion: 5,
|
|
12
12
|
id: requireIdentity(id, "Project id"),
|
|
13
13
|
name: validateProjectName(name),
|
|
14
14
|
aliases: normalizeAliases(metadata.aliases ?? [], name),
|
|
@@ -19,6 +19,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
|
19
19
|
: { remoteUrl: requireText(metadata.remoteUrl, "Project remote URL") }),
|
|
20
20
|
stableBranch: requireGitRef(branches.stable, "Project stable branch"),
|
|
21
21
|
developmentBranch: requireGitRef(branches.development, "Project development branch"),
|
|
22
|
+
status: "active",
|
|
22
23
|
knowledge: [],
|
|
23
24
|
knowledgeProposals: [],
|
|
24
25
|
createdAt: timestamp,
|
|
@@ -28,6 +29,38 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
|
28
29
|
export function validateProjectName(value) {
|
|
29
30
|
return requireIdentity(value, "Project name");
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Soft-retire a Project: mark it retired and record the audit trail. The
|
|
34
|
+
* catalog record, checkout, and every historical Task/Run/Review/Integration/
|
|
35
|
+
* Publication reference are retained. Retiring an already-retired Project
|
|
36
|
+
* fails closed; a retired Project must be deleted (or kept) as-is.
|
|
37
|
+
*/
|
|
38
|
+
export function retireProject(project, reason, by, now) {
|
|
39
|
+
if (project.status === "retired") {
|
|
40
|
+
throw new Error(`Project is already retired: ${project.id}.`);
|
|
41
|
+
}
|
|
42
|
+
const timestamp = now.toISOString();
|
|
43
|
+
return validateProject({
|
|
44
|
+
...project,
|
|
45
|
+
status: "retired",
|
|
46
|
+
retirement: {
|
|
47
|
+
reason: requireText(reason, "Project retirement reason"),
|
|
48
|
+
retiredBy: by,
|
|
49
|
+
retiredAt: timestamp
|
|
50
|
+
},
|
|
51
|
+
updatedAt: timestamp
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Refuse a mutation or new binding against a retired Project. Read-only
|
|
56
|
+
* inspection (`project show`, `project diagnose`, knowledge reads) remains
|
|
57
|
+
* allowed, so historical evidence stays auditable.
|
|
58
|
+
*/
|
|
59
|
+
export function assertProjectActive(project, action) {
|
|
60
|
+
if (project.status === "retired") {
|
|
61
|
+
throw new Error(`Project is retired and cannot ${action}: ${project.id}.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
31
64
|
export function addProjectKnowledge(project, id, title, body, now, provenance) {
|
|
32
65
|
const timestamp = now.toISOString();
|
|
33
66
|
const knowledge = {
|
|
@@ -243,8 +276,8 @@ export function assertProjectCatalog(projects) {
|
|
|
243
276
|
}
|
|
244
277
|
}
|
|
245
278
|
export function validateProject(project) {
|
|
246
|
-
if (project.schemaVersion !==
|
|
247
|
-
throw new Error("Project must use schemaVersion
|
|
279
|
+
if (project.schemaVersion !== 5) {
|
|
280
|
+
throw new Error("Project must use schemaVersion 5.");
|
|
248
281
|
}
|
|
249
282
|
requireIdentity(project.id, "Project id");
|
|
250
283
|
requireIdentity(project.name, "Project name");
|
|
@@ -252,6 +285,23 @@ export function validateProject(project) {
|
|
|
252
285
|
if (project.ownership !== "managed" && project.ownership !== "external") {
|
|
253
286
|
throw new Error(`Project ownership is invalid: ${String(project.ownership)}.`);
|
|
254
287
|
}
|
|
288
|
+
if (project.status !== "active" && project.status !== "retired") {
|
|
289
|
+
throw new Error(`Project status is invalid: ${String(project.status)}.`);
|
|
290
|
+
}
|
|
291
|
+
if (project.status === "retired") {
|
|
292
|
+
const retirement = project.retirement;
|
|
293
|
+
if (retirement === undefined) {
|
|
294
|
+
throw new Error(`Retired Project must carry its retirement record: ${project.id}.`);
|
|
295
|
+
}
|
|
296
|
+
requireText(retirement.reason, "Project retirement reason");
|
|
297
|
+
if (retirement.retiredBy !== "user" && retirement.retiredBy !== "operator") {
|
|
298
|
+
throw new Error("Project retirement actor is invalid.");
|
|
299
|
+
}
|
|
300
|
+
requireTimestamp(retirement.retiredAt, "Project retirement retiredAt");
|
|
301
|
+
}
|
|
302
|
+
else if (project.retirement !== undefined) {
|
|
303
|
+
throw new Error(`Active Project must not carry a retirement record: ${project.id}.`);
|
|
304
|
+
}
|
|
255
305
|
const references = new Set();
|
|
256
306
|
for (const reference of [project.id, project.name, ...project.aliases]) {
|
|
257
307
|
const folded = reference.toLocaleLowerCase();
|
|
@@ -18,6 +18,8 @@ const PROJECT_FROM_VERSION = 2;
|
|
|
18
18
|
const PROJECT_TO_VERSION = 3;
|
|
19
19
|
const PROJECT_KNOWLEDGE_PROPOSALS_FROM_VERSION = 3;
|
|
20
20
|
const PROJECT_KNOWLEDGE_PROPOSALS_TO_VERSION = 4;
|
|
21
|
+
const PROJECT_LIFECYCLE_FROM_VERSION = 4;
|
|
22
|
+
const PROJECT_LIFECYCLE_TO_VERSION = 5;
|
|
21
23
|
const TASK_FROM_VERSION = 3;
|
|
22
24
|
const TASK_TO_VERSION = 4;
|
|
23
25
|
const TASK_INTENT_FROM_VERSION = 4;
|
|
@@ -145,6 +147,7 @@ export function createProductionStorageRegistry() {
|
|
|
145
147
|
.registerOfflineMigration(projectOwnershipStep())
|
|
146
148
|
.registerOfflineMigration(configV2Step())
|
|
147
149
|
.registerCompatible(projectKnowledgeProposalsStep())
|
|
150
|
+
.registerCompatible(projectLifecycleStep())
|
|
148
151
|
.registerOfflineMigration(taskWorkspaceIdentityStep())
|
|
149
152
|
.registerOfflineMigration(taskIntentStep())
|
|
150
153
|
.registerOfflineMigration(recordFamilyStep("workItem", WORK_ITEM_FROM_VERSION, WORK_ITEM_TO_VERSION, "workItems"))
|
|
@@ -2761,6 +2764,91 @@ function normalizeProjectV3ToV4(snapshot) {
|
|
|
2761
2764
|
state: { ...snapshot.state, projects: nextProjects }
|
|
2762
2765
|
};
|
|
2763
2766
|
}
|
|
2767
|
+
/**
|
|
2768
|
+
* Project v5 adds the lifecycle status (`active`/`retired`) plus the
|
|
2769
|
+
* retirement audit record. Historical Projects are all active; the step only
|
|
2770
|
+
* defaults the new field, so it is a compatible adjacent migration.
|
|
2771
|
+
*/
|
|
2772
|
+
function projectLifecycleStep() {
|
|
2773
|
+
return {
|
|
2774
|
+
kind: "compatible",
|
|
2775
|
+
axis: "record",
|
|
2776
|
+
recordKind: "project",
|
|
2777
|
+
fromVersion: PROJECT_LIFECYCLE_FROM_VERSION,
|
|
2778
|
+
toVersion: PROJECT_LIFECYCLE_TO_VERSION,
|
|
2779
|
+
defaults: [
|
|
2780
|
+
"status defaults to active on every Project"
|
|
2781
|
+
],
|
|
2782
|
+
validateSource: (snapshot) => requireProjectV4Shape(snapshot),
|
|
2783
|
+
normalize: (snapshot) => normalizeProjectV4ToV5(snapshot)
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
function requireProjectV4Shape(snapshot) {
|
|
2787
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
2788
|
+
if (manifestVersions.project !== PROJECT_LIFECYCLE_FROM_VERSION) {
|
|
2789
|
+
throw new Error(`Record project compatible step requires manifest version ${PROJECT_LIFECYCLE_FROM_VERSION}.`);
|
|
2790
|
+
}
|
|
2791
|
+
if (snapshot.state === null)
|
|
2792
|
+
return;
|
|
2793
|
+
const projects = snapshot.state.projects;
|
|
2794
|
+
if (projects === undefined)
|
|
2795
|
+
return;
|
|
2796
|
+
const map = asObject(projects, "project map");
|
|
2797
|
+
const allowed = new Set(PROJECT_V4_FIELDS);
|
|
2798
|
+
for (const [projectId, rawProject] of Object.entries(map)) {
|
|
2799
|
+
const project = asObject(rawProject, `Project ${projectId}`);
|
|
2800
|
+
if (project.schemaVersion !== PROJECT_LIFECYCLE_FROM_VERSION) {
|
|
2801
|
+
throw new Error(`Project ${projectId} must use schemaVersion ${PROJECT_LIFECYCLE_FROM_VERSION}.`);
|
|
2802
|
+
}
|
|
2803
|
+
const unknown = Object.keys(project).find((key) => !allowed.has(key));
|
|
2804
|
+
if (unknown !== undefined) {
|
|
2805
|
+
throw new Error(`Project ${projectId} has an unknown v4 field: ${unknown}.`);
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
function normalizeProjectV4ToV5(snapshot) {
|
|
2810
|
+
requireProjectV4Shape(snapshot);
|
|
2811
|
+
const manifestVersions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
|
|
2812
|
+
const schemaManifest = {
|
|
2813
|
+
...snapshot.schemaManifest,
|
|
2814
|
+
recordVersions: { ...manifestVersions, project: PROJECT_LIFECYCLE_TO_VERSION }
|
|
2815
|
+
};
|
|
2816
|
+
if (snapshot.state === null)
|
|
2817
|
+
return { schemaManifest, state: null };
|
|
2818
|
+
const projects = snapshot.state.projects;
|
|
2819
|
+
if (projects === undefined) {
|
|
2820
|
+
return { schemaManifest, state: { ...snapshot.state } };
|
|
2821
|
+
}
|
|
2822
|
+
const map = asObject(projects, "project map");
|
|
2823
|
+
const nextProjects = {};
|
|
2824
|
+
for (const [projectId, rawProject] of Object.entries(map)) {
|
|
2825
|
+
const project = asObject(rawProject, `Project ${projectId}`);
|
|
2826
|
+
nextProjects[projectId] = {
|
|
2827
|
+
...project,
|
|
2828
|
+
schemaVersion: PROJECT_LIFECYCLE_TO_VERSION,
|
|
2829
|
+
status: "active"
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
return {
|
|
2833
|
+
schemaManifest,
|
|
2834
|
+
state: { ...snapshot.state, projects: nextProjects }
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
const PROJECT_V4_FIELDS = [
|
|
2838
|
+
"schemaVersion",
|
|
2839
|
+
"id",
|
|
2840
|
+
"name",
|
|
2841
|
+
"aliases",
|
|
2842
|
+
"path",
|
|
2843
|
+
"ownership",
|
|
2844
|
+
"remoteUrl",
|
|
2845
|
+
"stableBranch",
|
|
2846
|
+
"developmentBranch",
|
|
2847
|
+
"knowledge",
|
|
2848
|
+
"knowledgeProposals",
|
|
2849
|
+
"createdAt",
|
|
2850
|
+
"updatedAt"
|
|
2851
|
+
];
|
|
2764
2852
|
/**
|
|
2765
2853
|
* Task v4 adds the optional durable workspace identity. Historical Tasks have
|
|
2766
2854
|
* no identity; they keep working against their existing (legacy) refs until the
|
|
@@ -596,7 +596,43 @@ export class SqliteTaskStore {
|
|
|
596
596
|
return this.#getPayload("projects", "id = ?", [id]);
|
|
597
597
|
}
|
|
598
598
|
removeProject(id) {
|
|
599
|
-
return this.#mutate(() =>
|
|
599
|
+
return this.#mutate(() => {
|
|
600
|
+
if (this.listTasks().some((task) => task.projectBindings.some((binding) => binding.projectId === id))) {
|
|
601
|
+
throw new StorageRecordError(`Project is still used by a Task: ${id}`);
|
|
602
|
+
}
|
|
603
|
+
return this.#db.prepare("DELETE FROM projects WHERE id = ?").run(id).changes > 0;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
summarizeProjectReferences(projectId) {
|
|
607
|
+
const boundTasks = this.listTasks().filter((task) => task.projectBindings.some((binding) => binding.projectId === projectId));
|
|
608
|
+
const activeTasks = boundTasks.filter((task) => task.status === "active");
|
|
609
|
+
const unresolvedWorkItemRefs = [];
|
|
610
|
+
const activeRunRefs = [];
|
|
611
|
+
const unresolvedIntegrationRefs = [];
|
|
612
|
+
for (const task of activeTasks) {
|
|
613
|
+
for (const workItem of this.listWorkItems(task.id)) {
|
|
614
|
+
if (workItem.status !== "completed" && workItem.status !== "retired") {
|
|
615
|
+
unresolvedWorkItemRefs.push(`${task.id}/${workItem.id}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
for (const run of this.listAgentRuns(task.id)) {
|
|
619
|
+
if (run.status === "active")
|
|
620
|
+
activeRunRefs.push(`${task.id}/${run.id}`);
|
|
621
|
+
}
|
|
622
|
+
for (const attempt of this.listIntegrationAttempts(task.id)) {
|
|
623
|
+
if (attempt.status === "running" || attempt.status === "blocked") {
|
|
624
|
+
unresolvedIntegrationRefs.push(`${task.id}/${attempt.id}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
projectId,
|
|
630
|
+
boundTaskIds: boundTasks.map(({ id }) => id),
|
|
631
|
+
activeTaskIds: activeTasks.map(({ id }) => id),
|
|
632
|
+
unresolvedWorkItemRefs,
|
|
633
|
+
activeRunRefs,
|
|
634
|
+
unresolvedIntegrationRefs
|
|
635
|
+
};
|
|
600
636
|
}
|
|
601
637
|
// -- agent profiles ---------------------------------------------------------
|
|
602
638
|
saveAgentProfile(profile) {
|
|
@@ -1485,9 +1521,10 @@ export class SqliteTaskStore {
|
|
|
1485
1521
|
if (round === null) {
|
|
1486
1522
|
throw new StorageRecordError(`Agent run ReviewRound not found: ${run.reviewRoundId}.`);
|
|
1487
1523
|
}
|
|
1524
|
+
const roundWorkItemId = round.workItemId ?? round.legacyAnchor?.workItemId;
|
|
1488
1525
|
const laneRole = round.executionGroup?.lanes
|
|
1489
1526
|
.find(({ id }) => id === run.executionLaneId)?.roleName;
|
|
1490
|
-
if (
|
|
1527
|
+
if (roundWorkItemId !== run.workItemId
|
|
1491
1528
|
|| (round.reviewerRoleName !== run.roleName && laneRole !== run.roleName)) {
|
|
1492
1529
|
throw new StorageRecordError(`Agent run does not match ReviewRound: ${run.id}.`);
|
|
1493
1530
|
}
|
|
@@ -58,7 +58,7 @@ export const CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION = 3;
|
|
|
58
58
|
* below, but are not independent record-axis families.
|
|
59
59
|
*/
|
|
60
60
|
export const CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION = 2;
|
|
61
|
-
export const CURRENT_PROJECT_SCHEMA_VERSION =
|
|
61
|
+
export const CURRENT_PROJECT_SCHEMA_VERSION = 5;
|
|
62
62
|
export const CURRENT_AGENT_PROFILE_SCHEMA_VERSION = 2;
|
|
63
63
|
export const CURRENT_GLOBAL_ROLE_SCHEMA_VERSION = 3;
|
|
64
64
|
export const CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION = 3;
|
|
@@ -279,6 +279,37 @@ export class FileTaskStore {
|
|
|
279
279
|
return this.#remove((state) => state.projects, id);
|
|
280
280
|
});
|
|
281
281
|
}
|
|
282
|
+
summarizeProjectReferences(projectId) {
|
|
283
|
+
const boundTasks = this.listTasks().filter((task) => task.projectBindings.some((binding) => binding.projectId === projectId));
|
|
284
|
+
const activeTasks = boundTasks.filter((task) => task.status === "active");
|
|
285
|
+
const unresolvedWorkItemRefs = [];
|
|
286
|
+
const activeRunRefs = [];
|
|
287
|
+
const unresolvedIntegrationRefs = [];
|
|
288
|
+
for (const task of activeTasks) {
|
|
289
|
+
for (const workItem of this.listWorkItems(task.id)) {
|
|
290
|
+
if (workItem.status !== "completed" && workItem.status !== "retired") {
|
|
291
|
+
unresolvedWorkItemRefs.push(`${task.id}/${workItem.id}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
for (const run of this.listAgentRuns(task.id)) {
|
|
295
|
+
if (run.status === "active")
|
|
296
|
+
activeRunRefs.push(`${task.id}/${run.id}`);
|
|
297
|
+
}
|
|
298
|
+
for (const attempt of this.listIntegrationAttempts(task.id)) {
|
|
299
|
+
if (attempt.status === "running" || attempt.status === "blocked") {
|
|
300
|
+
unresolvedIntegrationRefs.push(`${task.id}/${attempt.id}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
projectId,
|
|
306
|
+
boundTaskIds: boundTasks.map(({ id }) => id),
|
|
307
|
+
activeTaskIds: activeTasks.map(({ id }) => id),
|
|
308
|
+
unresolvedWorkItemRefs,
|
|
309
|
+
activeRunRefs,
|
|
310
|
+
unresolvedIntegrationRefs
|
|
311
|
+
};
|
|
312
|
+
}
|
|
282
313
|
saveAgentProfile(profile) {
|
|
283
314
|
const stored = identified(profile, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, "id", profile.id, "Agent Profile");
|
|
284
315
|
validateAgentProfile(stored);
|
|
@@ -998,8 +1029,9 @@ export class FileTaskStore {
|
|
|
998
1029
|
if (round === undefined) {
|
|
999
1030
|
throw new StorageRecordError(`Agent run ReviewRound not found: ${stored.reviewRoundId}.`);
|
|
1000
1031
|
}
|
|
1032
|
+
const roundWorkItemId = round.workItemId ?? round.legacyAnchor?.workItemId;
|
|
1001
1033
|
const laneRole = round.executionGroup?.lanes.find(({ id }) => id === stored.executionLaneId)?.roleName;
|
|
1002
|
-
if (
|
|
1034
|
+
if (roundWorkItemId !== stored.workItemId
|
|
1003
1035
|
|| (round.reviewerRoleName !== stored.roleName && laneRole !== stored.roleName)) {
|
|
1004
1036
|
throw new StorageRecordError(`Agent run does not match ReviewRound: ${stored.id}.`);
|
|
1005
1037
|
}
|