@zq-silk/yui 0.6.0 → 0.6.2

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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. package/skills/yui-leader/SKILL.md +8 -0
@@ -0,0 +1,374 @@
1
+ import { dataError, taskNotFound, usageError } from "../errors/cliError.js";
2
+ import { createTaskEvent } from "../event/taskEvent.js";
3
+ import { createCapabilityGrant, revokeGrant } from "../grant/capabilityGrant.js";
4
+ import { defaultTableWidth, renderTable } from "../output/table.js";
5
+ import { formatTimestamp } from "../output/timePresentation.js";
6
+ import { isCurrentGlobalOperator } from "./taskInputCommands.js";
7
+ const CEILINGS = new Set(["none", "reversible", "irreversible"]);
8
+ export function runGrantCommand(args, store, options) {
9
+ const [command, ...rest] = args;
10
+ switch (command) {
11
+ case "issue": return issueGrant(rest, store, options);
12
+ case "show": return showGrant(rest, store);
13
+ case "list": return listGrants(rest, store);
14
+ case "revoke": return revokeGrantCommand(rest, store, options);
15
+ default:
16
+ throw usageError(command === undefined
17
+ ? "Task grant command is required."
18
+ : `Unknown command: task grant ${command}`);
19
+ }
20
+ }
21
+ function issueGrant(args, store, options) {
22
+ const usage = "Task grant issue usage: yui task grant issue <task> --action <name> (repeatable) [--scope-project <id>...] [--scope-repo <owner/name>...] [--scope-package <name>...] [--scope-home <path>] [--param <name=v1,v2>...] [--expires-at <iso-8601>] [--max-uses <int>] [--irreversibility-ceiling <none|reversible|irreversible>].";
23
+ const parsed = parseMultiValueTail(args, new Set(["--scope-home", "--expires-at", "--max-uses", "--irreversibility-ceiling"]), new Set(["--action", "--scope-project", "--scope-repo", "--scope-package", "--param"]), usage);
24
+ exactPositionals(parsed.positionals, 1, usage);
25
+ const taskId = parsed.positionals[0];
26
+ requireTask(store, taskId);
27
+ const actions = parsed.multiOptions.get("--action") ?? [];
28
+ if (actions.length === 0)
29
+ throw usageError("--action is required.", usage);
30
+ const scope = buildScope(parsed, usage);
31
+ const parameterBounds = buildParameterBounds(parsed, usage);
32
+ const expiresAt = optionalOption(parsed.options, "--expires-at");
33
+ const maxUses = parseMaxUses(parsed, usage);
34
+ const ceiling = parseCeiling(parsed, usage);
35
+ // Grant issuance mints irreversible authority. The granter is bound to the
36
+ // authenticated global Operator Session, not to a caller-supplied label.
37
+ const env = options.environment ?? {};
38
+ const granter = authorizeGrantOrigin(store, env, usage);
39
+ const now = clock(options);
40
+ // The grant record and its audit event commit in one transaction: a failed
41
+ // event save cannot leave a persisted grant without its audit trail.
42
+ const grant = store.transaction((tx) => {
43
+ const issued = createCapabilityGrant(tx.nextCapabilityGrantId(taskId), taskId, {
44
+ granter,
45
+ ...(scope === undefined ? {} : { scope }),
46
+ actions,
47
+ ...(parameterBounds === undefined ? {} : { parameterBounds }),
48
+ ...(expiresAt === undefined ? {} : { expiresAt }),
49
+ ...(maxUses === undefined ? {} : { maxUses }),
50
+ ...(ceiling === undefined ? {} : { irreversibilityCeiling: ceiling })
51
+ }, now);
52
+ tx.saveCapabilityGrant(taskId, issued);
53
+ recordTaskEvent(tx, taskId, "capability-grant.issued", {
54
+ grantId: issued.id,
55
+ granter: issued.granter,
56
+ actions: issued.actions.join(","),
57
+ scope: scopeSummary(issued.scope)
58
+ }, now);
59
+ return issued;
60
+ });
61
+ return output(`Granted ${grant.id} to ${grant.taskId}.\n`, grant);
62
+ }
63
+ /**
64
+ * Authorizes a grant mutation (issue or revoke) and returns the origin-bound
65
+ * identity to record as the granter/revoker.
66
+ *
67
+ * Grant issue and revoke carry irreversible authority: they mint or revoke
68
+ * the capability that lets a managed Agent perform irreversible site changes.
69
+ * The only accepted origin is the authenticated global Operator Session,
70
+ * whose env claims are verified against the durable live session binding by
71
+ * isCurrentGlobalOperator.
72
+ *
73
+ * Absence of YUI identity vars is deliberately NOT treated as user authority:
74
+ * a managed Agent can clear its child-process environment, so a "clean"
75
+ * environment is indistinguishable from a scrubbed managed one. A Task-scoped
76
+ * Session, a partial managed identity, and a stale/forged global binding are
77
+ * all refused. Caller-supplied --granter/--by labels are not identity; the
78
+ * recorded granter/revoker is bound to the Operator Session instead.
79
+ */
80
+ function authorizeGrantOrigin(store, env, usage) {
81
+ if (!isCurrentGlobalOperator(store, env)) {
82
+ throw usageError("Grant issue and revoke require the authenticated global Operator " +
83
+ "session. A managed Agent cannot self-issue or self-revoke a " +
84
+ "capability grant, and a clean environment is not user authority.", usage);
85
+ }
86
+ // isCurrentGlobalOperator verified YUI_AGENT_ID against the durable binding.
87
+ return `operator:${env.YUI_AGENT_ID ?? "unknown"}`;
88
+ }
89
+ function showGrant(args, store) {
90
+ const usage = "Task grant show usage: yui task grant show <task> <grant-id>.";
91
+ const parsed = parseTail(args, new Set(), usage);
92
+ exactPositionals(parsed.positionals, 2, usage);
93
+ const taskId = parsed.positionals[0];
94
+ requireTask(store, taskId);
95
+ const grant = store.getCapabilityGrant(taskId, parsed.positionals[1]);
96
+ if (grant === null) {
97
+ throw dataError(`Capability grant not found: ${taskId}/${parsed.positionals[1]}.`);
98
+ }
99
+ return output(renderGrant(grant, store.getConfig().timeZone), grant);
100
+ }
101
+ function listGrants(args, store) {
102
+ const usage = "Task grant list usage: yui task grant list <task>.";
103
+ const parsed = parseTail(args, new Set(), usage);
104
+ exactPositionals(parsed.positionals, 1, usage);
105
+ const taskId = parsed.positionals[0];
106
+ requireTask(store, taskId);
107
+ const grants = store.listCapabilityGrants(taskId);
108
+ if (grants.length === 0) {
109
+ return output("No capability grants found.\n", []);
110
+ }
111
+ const timeZone = store.getConfig().timeZone;
112
+ const rendered = `${renderTable(`Capability grants: ${taskId}`, [
113
+ { header: "ID", minWidth: 6, maxWidth: 20 },
114
+ { header: "Granter", minWidth: 6, maxWidth: 18 },
115
+ { header: "Actions", minWidth: 8, maxWidth: 40 },
116
+ { header: "Expires", minWidth: 10, maxWidth: 28 },
117
+ { header: "Uses", minWidth: 5, maxWidth: 10 },
118
+ { header: "Ceiling", minWidth: 8, maxWidth: 14 },
119
+ { header: "Revoked", minWidth: 8, maxWidth: 28 }
120
+ ], grants.map((grant) => [
121
+ grant.id,
122
+ grant.granter,
123
+ grant.actions.join(","),
124
+ grant.expiresAt === undefined ? "-" : formatTimestamp(grant.expiresAt, timeZone),
125
+ grant.maxUses === undefined ? `${grant.usesUsed}` : `${grant.usesUsed}/${grant.maxUses}`,
126
+ grant.irreversibilityCeiling,
127
+ grant.revokedAt === undefined ? "no" : formatTimestamp(grant.revokedAt, timeZone)
128
+ ]), defaultTableWidth())}\n`;
129
+ return output(rendered, grants);
130
+ }
131
+ function revokeGrantCommand(args, store, options) {
132
+ const usage = "Task grant revoke usage: yui task grant revoke <task> <grant-id>.";
133
+ const parsed = parseTail(args, new Set(), usage);
134
+ exactPositionals(parsed.positionals, 2, usage);
135
+ const taskId = parsed.positionals[0];
136
+ requireTask(store, taskId);
137
+ const existing = store.getCapabilityGrant(taskId, parsed.positionals[1]);
138
+ if (existing === null) {
139
+ throw dataError(`Capability grant not found: ${taskId}/${parsed.positionals[1]}.`);
140
+ }
141
+ // Revoke is the same irreversible-authority operation as issue: it requires
142
+ // the authenticated Operator Session, and the revoker is bound to it.
143
+ const env = options.environment ?? {};
144
+ const by = authorizeGrantOrigin(store, env, usage);
145
+ const wasRevoked = existing.revokedAt !== undefined;
146
+ const now = clock(options);
147
+ const grant = revokeGrant(existing, by, now);
148
+ // The revoked grant and its audit event commit in one transaction: a failed
149
+ // event save cannot leave a revoked grant without its audit trail.
150
+ store.transaction((tx) => {
151
+ tx.saveCapabilityGrant(taskId, grant);
152
+ if (!wasRevoked) {
153
+ recordTaskEvent(tx, taskId, "capability-grant.revoked", {
154
+ grantId: grant.id,
155
+ revokedBy: grant.revokedBy ?? by
156
+ }, now);
157
+ }
158
+ });
159
+ return output(`Revoked ${grant.id}.\n`, grant);
160
+ }
161
+ function buildScope(parsed, usage) {
162
+ const projectIds = parsed.multiOptions.get("--scope-project") ?? [];
163
+ const repositories = (parsed.multiOptions.get("--scope-repo") ?? []).map((value) => {
164
+ const separator = value.indexOf("/");
165
+ if (separator <= 0 || separator === value.length - 1) {
166
+ throw usageError("--scope-repo must use owner/name.", usage);
167
+ }
168
+ return {
169
+ owner: value.slice(0, separator).trim(),
170
+ name: value.slice(separator + 1).trim()
171
+ };
172
+ });
173
+ const packages = parsed.multiOptions.get("--scope-package") ?? [];
174
+ const homePath = optionalOption(parsed.options, "--scope-home");
175
+ if (projectIds.length === 0
176
+ && repositories.length === 0
177
+ && packages.length === 0
178
+ && homePath === undefined) {
179
+ return undefined;
180
+ }
181
+ return {
182
+ ...(projectIds.length > 0 ? { projectIds } : {}),
183
+ ...(repositories.length > 0 ? { repositories } : {}),
184
+ ...(packages.length > 0 ? { packages } : {}),
185
+ ...(homePath === undefined ? {} : { homePath })
186
+ };
187
+ }
188
+ function buildParameterBounds(parsed, usage) {
189
+ const paramValues = parsed.multiOptions.get("--param") ?? [];
190
+ if (paramValues.length === 0)
191
+ return undefined;
192
+ const bounds = {};
193
+ for (const value of paramValues) {
194
+ const separator = value.indexOf("=");
195
+ if (separator <= 0 || separator === value.length - 1) {
196
+ throw usageError("--param must use name=value1,value2.", usage);
197
+ }
198
+ const name = value.slice(0, separator).trim();
199
+ const allowed = value.slice(separator + 1)
200
+ .split(",")
201
+ .map((entry) => entry.trim())
202
+ .filter((entry) => entry.length > 0);
203
+ if (allowed.length === 0) {
204
+ throw usageError(`--param must list at least one value: ${name}.`, usage);
205
+ }
206
+ if (Object.hasOwn(bounds, name)) {
207
+ throw usageError(`--param may only be specified once per name: ${name}.`, usage);
208
+ }
209
+ bounds[name] = allowed;
210
+ }
211
+ return bounds;
212
+ }
213
+ function parseMaxUses(parsed, usage) {
214
+ const value = optionalOption(parsed.options, "--max-uses");
215
+ if (value === undefined)
216
+ return undefined;
217
+ if (!/^[1-9][0-9]*$/.test(value)) {
218
+ throw usageError("--max-uses must be a positive integer.", usage);
219
+ }
220
+ return Number(value);
221
+ }
222
+ function parseCeiling(parsed, usage) {
223
+ const value = optionalOption(parsed.options, "--irreversibility-ceiling");
224
+ if (value === undefined)
225
+ return undefined;
226
+ if (!CEILINGS.has(value)) {
227
+ throw usageError("--irreversibility-ceiling must be one of: none, reversible, irreversible.", usage);
228
+ }
229
+ return value;
230
+ }
231
+ function scopeSummary(scope) {
232
+ const parts = [];
233
+ if (scope.taskId !== undefined)
234
+ parts.push(`task:${scope.taskId}`);
235
+ if (scope.projectIds !== undefined && scope.projectIds.length > 0) {
236
+ parts.push(`project:${scope.projectIds.join(",")}`);
237
+ }
238
+ if (scope.repositories !== undefined && scope.repositories.length > 0) {
239
+ parts.push(`repo:${scope.repositories.map((repo) => `${repo.owner}/${repo.name}`).join(",")}`);
240
+ }
241
+ if (scope.packages !== undefined && scope.packages.length > 0) {
242
+ parts.push(`package:${scope.packages.join(",")}`);
243
+ }
244
+ if (scope.homePath !== undefined)
245
+ parts.push(`home:${scope.homePath}`);
246
+ return parts.join(";");
247
+ }
248
+ function renderGrant(grant, timeZone) {
249
+ return [
250
+ `Grant: ${grant.id}`,
251
+ `Task: ${grant.taskId}`,
252
+ `Granter: ${grant.granter}`,
253
+ `Scope: ${scopeSummary(grant.scope)}`,
254
+ `Actions: ${grant.actions.join(", ")}`,
255
+ ...(Object.keys(grant.parameterBounds).length === 0
256
+ ? []
257
+ : [
258
+ "Parameters:",
259
+ ...Object.entries(grant.parameterBounds)
260
+ .map(([name, values]) => ` ${name}: ${values.join(", ")}`)
261
+ ]),
262
+ ...(grant.expiresAt === undefined
263
+ ? []
264
+ : [`Expires: ${formatTimestamp(grant.expiresAt, timeZone)}`]),
265
+ ...(grant.maxUses === undefined ? [] : [`Max uses: ${grant.maxUses}`]),
266
+ `Uses used: ${grant.usesUsed}`,
267
+ `Ceiling: ${grant.irreversibilityCeiling}`,
268
+ ...(grant.revokedAt === undefined
269
+ ? ["Revoked: no"]
270
+ : [
271
+ "Revoked: yes",
272
+ `Revoked by: ${grant.revokedBy}`,
273
+ `Revoked at: ${formatTimestamp(grant.revokedAt, timeZone)}`
274
+ ]),
275
+ `Created: ${formatTimestamp(grant.createdAt, timeZone)}`,
276
+ `Updated: ${formatTimestamp(grant.updatedAt, timeZone)}`
277
+ ].join("\n").concat("\n");
278
+ }
279
+ function requireTask(store, taskId) {
280
+ const id = requiredText(taskId, "Task id");
281
+ const task = store.getTask(id);
282
+ if (task === null)
283
+ throw taskNotFound(id);
284
+ return task;
285
+ }
286
+ function recordTaskEvent(store, taskId, type, payload, now) {
287
+ store.saveEvent(taskId, createTaskEvent(store.nextEventId(taskId), taskId, type, payload, now));
288
+ }
289
+ function requiredOption(options, name) {
290
+ return requiredText(options.get(name), name);
291
+ }
292
+ function optionalOption(options, name) {
293
+ if (!options.has(name))
294
+ return undefined;
295
+ return requiredText(options.get(name), name);
296
+ }
297
+ function requiredText(value, label) {
298
+ const normalized = value?.trim();
299
+ if (normalized === undefined || normalized.length === 0) {
300
+ throw usageError(`${label} is required.`);
301
+ }
302
+ return normalized;
303
+ }
304
+ function clock(options) {
305
+ return options.now?.() ?? new Date();
306
+ }
307
+ function output(value, data) {
308
+ return data === undefined
309
+ ? { kind: "output", output: value }
310
+ : { kind: "output", output: value, data };
311
+ }
312
+ function exactPositionals(values, count, usage) {
313
+ if (values.length !== count || values.some((value) => value.trim().length === 0)) {
314
+ throw usageError(usage);
315
+ }
316
+ }
317
+ function parseTail(args, valueOptions, usage) {
318
+ const positionals = [];
319
+ const options = new Map();
320
+ for (let index = 0; index < args.length; index += 1) {
321
+ const value = args[index];
322
+ if (!value.startsWith("--")) {
323
+ positionals.push(value);
324
+ continue;
325
+ }
326
+ if (!valueOptions.has(value)) {
327
+ throw usageError(`Unsupported option: ${value}.`, usage);
328
+ }
329
+ if (options.has(value)) {
330
+ throw usageError(`Option may only be specified once: ${value}.`, usage);
331
+ }
332
+ const optionValue = args[index + 1];
333
+ if (optionValue === undefined || optionValue.startsWith("--")) {
334
+ throw usageError(`${value} is required.`, usage);
335
+ }
336
+ options.set(value, optionValue);
337
+ index += 1;
338
+ }
339
+ return { positionals, options };
340
+ }
341
+ function parseMultiValueTail(args, valueOptions, repeatOptions, usage) {
342
+ const positionals = [];
343
+ const options = new Map();
344
+ const multiOptions = new Map();
345
+ for (let index = 0; index < args.length; index += 1) {
346
+ const value = args[index];
347
+ if (!value.startsWith("--")) {
348
+ positionals.push(value);
349
+ continue;
350
+ }
351
+ if (!valueOptions.has(value) && !repeatOptions.has(value)) {
352
+ throw usageError(`Unsupported option: ${value}.`, usage);
353
+ }
354
+ if (repeatOptions.has(value)) {
355
+ const optionValue = args[index + 1];
356
+ if (optionValue === undefined || optionValue.startsWith("--")) {
357
+ throw usageError(`${value} is required.`, usage);
358
+ }
359
+ multiOptions.set(value, [...(multiOptions.get(value) ?? []), optionValue]);
360
+ index += 1;
361
+ continue;
362
+ }
363
+ if (options.has(value)) {
364
+ throw usageError(`Option may only be specified once: ${value}.`, usage);
365
+ }
366
+ const optionValue = args[index + 1];
367
+ if (optionValue === undefined || optionValue.startsWith("--")) {
368
+ throw usageError(`${value} is required.`, usage);
369
+ }
370
+ options.set(value, optionValue);
371
+ index += 1;
372
+ }
373
+ return { positionals, options, multiOptions };
374
+ }
@@ -4,6 +4,7 @@ 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
6
  import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
7
+ import { acquireProjectMaintenanceLock } from "../repository/projectMaintenanceLock.js";
7
8
  import { addProjectKnowledge, createProject, managedProjectPath, retireProjectKnowledge, resolveProject, updateProjectKnowledge, updateProjectMetadata, validateProject, validateProjectName } from "../repository/project.js";
8
9
  export async function runProjectCommand(args, store, options = {}) {
9
10
  const [command, ...rest] = args;
@@ -223,104 +224,141 @@ async function migrateProject(args, store, options) {
223
224
  }
224
225
  const git = options.git ?? new NodeGitWorkspace();
225
226
  const destination = managedProjectPath(store.rootDirectory(), project.id);
226
- // Preflight verifies the remote into a throwaway clone: it changes neither
227
- // the catalog nor the persistent projects directory.
228
- const verifyRoot = parsed.preflight
229
- ? join(store.rootDirectory(), "projects", `.preflight-${project.id}`)
230
- : destination;
231
- if (!parsed.preflight) {
232
- // A crashed earlier attempt can leave an unreferenced managed clone
233
- // behind. It is safe to remove only because no catalog record points at
234
- // it; a registered path fails closed instead.
235
- await removeUnreferencedClone(store, destination, project.id);
236
- }
237
- else if (existsSync(verifyRoot)) {
238
- // A crashed preflight can only leave its own throwaway clone behind.
239
- await rm(verifyRoot, { recursive: true, force: true });
240
- }
241
- let prepared = false;
227
+ // Migration rewrites the Project's Git repository: hold the per-Project
228
+ // maintenance fence so no rebuild/archive/cleanup (or a second migrate)
229
+ // interleaves, and the Controller defers worktree preparation meanwhile.
230
+ const releaseMaintenance = acquireProjectMaintenanceLock(store.rootDirectory(), project.id);
242
231
  try {
243
- const head = await git.clone({
244
- remoteUrl: project.remoteUrl,
245
- destination: verifyRoot,
246
- ...(project.stableBranch === "HEAD" ? {} : { branch: project.stableBranch })
247
- });
248
- prepared = true;
249
- const stable = project.stableBranch === "HEAD"
250
- ? head
251
- : await git.inspect(verifyRoot, project.stableBranch);
252
- if (head.baseCommit !== stable.baseCommit) {
253
- throw new Error(`Project checkout is not on its stable ref: ${project.stableBranch}.`);
254
- }
255
- if (project.developmentBranch !== project.stableBranch) {
256
- await git.ensureLocalBranch(verifyRoot, project.developmentBranch);
232
+ // Re-read the Project under the fence. A concurrent migration may have
233
+ // completed and switched the catalog to the Home-managed repo since the
234
+ // record was resolved above; the stale snapshot must not drive any Git
235
+ // effect (in particular it must not delete the now-canonical repo).
236
+ const current = requireProject(store, project.id);
237
+ if (current.ownership === "managed") {
238
+ throw usageError(`Project is already Home-managed: ${project.id}.`);
239
+ }
240
+ if (current.remoteUrl === undefined) {
241
+ throw usageError(`Project migrate requires a remote URL: ${project.id}.`);
242
+ }
243
+ if (current.path !== project.path
244
+ || current.remoteUrl !== project.remoteUrl
245
+ || current.stableBranch !== project.stableBranch
246
+ || current.developmentBranch !== project.developmentBranch) {
247
+ throw new Error(`Project changed while migrating: ${project.id}.`);
248
+ }
249
+ // Preflight verifies the remote into a throwaway clone: it changes neither
250
+ // the catalog nor the persistent projects directory.
251
+ const verifyRoot = parsed.preflight
252
+ ? join(store.rootDirectory(), "projects", `.preflight-${project.id}`)
253
+ : destination;
254
+ if (!parsed.preflight) {
255
+ // A crashed earlier attempt can leave an unreferenced managed clone
256
+ // behind. It is safe to remove only because no catalog record points at
257
+ // it; a registered path fails closed instead.
258
+ await removeUnreferencedClone(store, destination, project.id);
259
+ }
260
+ else if (existsSync(verifyRoot)) {
261
+ // A crashed preflight can only leave its own throwaway clone behind.
262
+ await rm(verifyRoot, { recursive: true, force: true });
257
263
  }
258
- await assertRemoteBranchesVerified(git, verifyRoot, project.remoteUrl, [
259
- { ref: project.stableBranch, localCommit: head.baseCommit },
260
- ...(project.developmentBranch === project.stableBranch
261
- ? []
262
- : [{
263
- ref: project.developmentBranch,
264
- localCommit: (await git.inspect(verifyRoot, project.developmentBranch)).baseCommit
265
- }])
266
- ]);
267
- const copyRefs = git.copyRefs;
268
- if (typeof copyRefs !== "function") {
269
- throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
270
- }
271
- await copyRefs.call(git, {
272
- sourceRepositoryPath: project.path,
273
- destinationRepositoryPath: verifyRoot,
274
- patterns: ["refs/heads/yui/", "refs/yui/archive/"]
275
- });
276
- if (parsed.preflight) {
277
- return { project, path: destination, preflight: true };
278
- }
279
- const switched = store.transaction((tx) => {
280
- const latest = requireProject(tx, project.id);
281
- if (latest.ownership !== "external"
282
- || latest.path !== project.path
283
- || latest.remoteUrl !== project.remoteUrl) {
284
- throw new Error(`Project changed while migrating: ${project.id}.`);
264
+ let prepared = false;
265
+ try {
266
+ // Every Git effect is driven from the under-fence snapshot `current`,
267
+ // never the pre-lock `project`: a branch/remote change between the read
268
+ // and the lock fails closed above, and the publication CAS below covers
269
+ // a change between the lock and the switch.
270
+ const head = await git.clone({
271
+ remoteUrl: current.remoteUrl,
272
+ destination: verifyRoot,
273
+ ...(current.stableBranch === "HEAD" ? {} : { branch: current.stableBranch })
274
+ });
275
+ prepared = true;
276
+ const stable = current.stableBranch === "HEAD"
277
+ ? head
278
+ : await git.inspect(verifyRoot, current.stableBranch);
279
+ if (head.baseCommit !== stable.baseCommit) {
280
+ throw new Error(`Project checkout is not on its stable ref: ${current.stableBranch}.`);
281
+ }
282
+ if (current.developmentBranch !== current.stableBranch) {
283
+ await git.ensureLocalBranch(verifyRoot, current.developmentBranch);
285
284
  }
286
- // The switch only changes ownership and path; every other field is
287
- // frozen, and the catalog validator re-checks the whole record.
288
- const next = validateProject({
289
- ...latest,
290
- path: destination,
291
- ownership: "managed",
292
- updatedAt: (options.now ?? (() => new Date()))().toISOString()
285
+ await assertRemoteBranchesVerified(git, verifyRoot, current.remoteUrl, [
286
+ { ref: current.stableBranch, localCommit: head.baseCommit },
287
+ ...(current.developmentBranch === current.stableBranch
288
+ ? []
289
+ : [{
290
+ ref: current.developmentBranch,
291
+ localCommit: (await git.inspect(verifyRoot, current.developmentBranch)).baseCommit
292
+ }])
293
+ ]);
294
+ const copyRefs = git.copyRefs;
295
+ if (typeof copyRefs !== "function") {
296
+ throw new Error(`Git workspace cannot preserve local Yui refs for Project: ${project.id}.`);
297
+ }
298
+ await copyRefs.call(git, {
299
+ sourceRepositoryPath: current.path,
300
+ destinationRepositoryPath: verifyRoot,
301
+ patterns: ["refs/heads/yui/", "refs/yui/archive/"]
293
302
  });
294
- assertProjectAvailable(tx, next, latest.id);
295
- tx.saveProject(next);
296
- return next;
297
- });
298
- return { project: switched, path: switched.path, preflight: false };
299
- }
300
- catch (error) {
301
- if (prepared && !parsed.preflight
302
- && !store.listProjects().some(({ path }) => path === destination)) {
303
- await rm(destination, { recursive: true, force: true });
303
+ if (parsed.preflight) {
304
+ return { project: current, path: destination, preflight: true };
305
+ }
306
+ const switched = store.transaction((tx) => {
307
+ const latest = requireProject(tx, project.id);
308
+ if (latest.ownership !== "external"
309
+ || latest.path !== current.path
310
+ || latest.remoteUrl !== current.remoteUrl
311
+ || latest.stableBranch !== current.stableBranch
312
+ || latest.developmentBranch !== current.developmentBranch) {
313
+ throw new Error(`Project changed while migrating: ${project.id}.`);
314
+ }
315
+ // The switch only changes ownership and path; every other field is
316
+ // frozen, and the catalog validator re-checks the whole record.
317
+ const next = validateProject({
318
+ ...latest,
319
+ path: destination,
320
+ ownership: "managed",
321
+ updatedAt: (options.now ?? (() => new Date()))().toISOString()
322
+ });
323
+ assertProjectAvailable(tx, next, latest.id);
324
+ tx.saveProject(next);
325
+ return next;
326
+ });
327
+ return { project: switched, path: switched.path, preflight: false };
328
+ }
329
+ catch (error) {
330
+ if (prepared && !parsed.preflight
331
+ && !store.listProjects().some(({ path }) => path === destination)) {
332
+ await rm(destination, { recursive: true, force: true });
333
+ }
334
+ throw error;
335
+ }
336
+ finally {
337
+ if (parsed.preflight) {
338
+ await rm(verifyRoot, { recursive: true, force: true });
339
+ }
304
340
  }
305
- throw error;
306
341
  }
307
342
  finally {
308
- if (parsed.preflight) {
309
- await rm(verifyRoot, { recursive: true, force: true });
310
- }
343
+ releaseMaintenance();
311
344
  }
312
345
  }
313
346
  /**
314
347
  * Remove a managed clone left behind by a crashed migration attempt. Such a
315
348
  * directory is unreferenced garbage; a path any catalog record points at is
316
- * never deleted.
349
+ * never deleted. The check fails closed even when the destination is
350
+ * registered to the migrating Project itself: a concurrent migration may
351
+ * already have completed and switched the catalog to this path, making it
352
+ * the canonical repository that must not be removed.
317
353
  */
318
354
  async function removeUnreferencedClone(store, destination, migratingProjectId) {
319
355
  if (!existsSync(destination))
320
356
  return;
321
357
  const registered = store.listProjects().find(({ path }) => path === destination);
322
- if (registered !== undefined && registered.id !== migratingProjectId) {
323
- throw new Error(`Managed Project path is already registered: ${destination}.`);
358
+ if (registered !== undefined) {
359
+ throw new Error(registered.id === migratingProjectId
360
+ ? `Project already migrated to the managed path: ${destination}.`
361
+ : `Managed Project path is already registered: ${destination}.`);
324
362
  }
325
363
  await rm(destination, { recursive: true, force: true });
326
364
  }