@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
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { realpathSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -14,9 +14,11 @@ import { taskRoleSessionTitle } from "../runtime/sessionTitle.js";
14
14
  import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
15
15
  import { isTaskOwnedWorkspace } from "../worktree/managedWorkspace.js";
16
16
  import { activeLiveRoleAgentSession } from "./agentExecutor.js";
17
- import { effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
17
+ import { effectiveLaunchSnapshotsCompatibleForTaskMain, effectiveLaunchSnapshotsCompatible, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
18
18
  import { YUI_CONTROL_PLANE_DESCRIPTOR, YUI_TASK_RUNTIME_DESCRIPTOR, assertExactTaskRuntimeState, createExactControlPlaneDescriptor, createExactTaskRuntimeDescriptor, exactControlPlaneCommandPrefix, exactControlPlaneDigest, exactTaskRuntimeDescriptorPath, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
19
+ import { readActiveReleasePointer } from "../release/runtimeRelease.js";
19
20
  import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
21
+ import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
20
22
  /** Builds managed native Agent launches from the authoritative Task records. */
21
23
  export class FileRoleLaunchPlanner {
22
24
  home;
@@ -27,6 +29,7 @@ export class FileRoleLaunchPlanner {
27
29
  #createNativeSessionId;
28
30
  #cliPath;
29
31
  #controlPlane;
32
+ #resourceRegistrarValue;
30
33
  constructor(home, store, options = {}) {
31
34
  this.home = home;
32
35
  this.store = store;
@@ -46,16 +49,38 @@ export class FileRoleLaunchPlanner {
46
49
  // Freeze the complete control identity before any native process exists.
47
50
  // This value is immutable for the planner/Controller lifetime and is never
48
51
  // rewritten through a shared PATH launcher.
52
+ // Issue 02: bind the active release build ID so a Session created after a
53
+ // handover cannot mutate a different control plane.
54
+ const activeRelease = readActiveReleasePointer(this.home);
49
55
  this.#controlPlane = createExactControlPlaneDescriptor({
50
56
  executable: process.execPath,
51
57
  cliEntry: this.#cliPath,
52
- yuiHome: this.home
58
+ yuiHome: this.home,
59
+ ...(activeRelease === null
60
+ ? {}
61
+ : {
62
+ buildId: activeRelease.buildId,
63
+ activeReleaseDigest: activeRelease.packageDigest
64
+ })
53
65
  });
54
66
  }
67
+ #resourceRegistrar() {
68
+ return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home);
69
+ }
55
70
  refreshAgentEnvironment(refresh) {
56
71
  this.#agentEnvironment = patchEnvironment(this.#agentEnvironment, refresh.sourceNames, refresh.sources);
57
72
  this.#nativeAgentEnvironment = patchEnvironment(this.#nativeAgentEnvironment, refresh.nativeNames, refresh.nativeSources);
58
73
  }
74
+ /**
75
+ * Commit the caller key for a task Session only after the runtime host has
76
+ * confirmed that it created a new native process. An ensure/resume request
77
+ * that reuses a live host must keep the old durable hash because its process
78
+ * still carries the old plaintext key.
79
+ */
80
+ commitTaskCallerKey(input) {
81
+ const hash = createHash("sha256").update(input.callerKey).digest("hex");
82
+ this.store.setJobCallerKeyHash(input.taskId, input.roleName, input.agentId, hash);
83
+ }
59
84
  /** Re-publishes provider-discovered native identity after its durable fold. */
60
85
  refreshTaskRuntimeDescriptor(input) {
61
86
  const task = this.store.getTask(input.taskId);
@@ -81,8 +106,20 @@ export class FileRoleLaunchPlanner {
81
106
  launchId: session.launchId,
82
107
  nativeSessionId: session.nativeSessionId
83
108
  });
109
+ if (descriptor.runId !== input.runId
110
+ || descriptor.launchId !== input.launchId
111
+ || descriptor.nativeSessionId !== input.nativeSessionId
112
+ || descriptor.agentId !== input.agentId
113
+ || descriptor.adapterId !== input.adapterId
114
+ || descriptor.workspace !== canonicalPath(input.workspace)) {
115
+ throw new Error("Prepared Task runtime generation is not current.");
116
+ }
84
117
  assertExactTaskRuntimeState(descriptor, this.store);
85
- writeTextFileAtomically(exactTaskRuntimeDescriptorPath(this.home, descriptor), `${serializeExactDescriptor(descriptor)}\n`);
118
+ // Publish only the current-control source. A reused native pane keeps its
119
+ // own stable source path; its Hook self-refreshes that source before the
120
+ // volatile fence instead of the Controller scanning history to find it.
121
+ const currentSource = exactTaskRuntimeDescriptorPath(this.home, descriptor);
122
+ this.#writeExactTaskRuntimeDescriptor(descriptor, currentSource);
86
123
  }
87
124
  plan(input) {
88
125
  const task = this.store.getTask(input.taskId);
@@ -175,7 +212,9 @@ export class FileRoleLaunchPlanner {
175
212
  const effective = input.effective ?? resolvedEffective;
176
213
  const existing = sessionSet?.sessions[effective.agentId];
177
214
  const compatibleExisting = existing !== undefined
178
- && effectiveLaunchSnapshotsCompatible(existing.effective, effective);
215
+ && (input.mode === "resume"
216
+ ? effectiveLaunchSnapshotsCompatibleForTaskMain(existing.effective, effective, runWorkspace ?? workspace)
217
+ : effectiveLaunchSnapshotsCompatible(existing.effective, effective));
179
218
  if (input.mode === "resume" && !compatibleExisting) {
180
219
  throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
181
220
  }
@@ -309,6 +348,9 @@ export class FileRoleLaunchPlanner {
309
348
  nativeSessionId: resumeNativeSessionId
310
349
  })
311
350
  : adapter.compileNew(compileInput);
351
+ if (owner.scope === "task" && sessionContext.managedContextFile !== undefined) {
352
+ this.#resourceRegistrar().registerSessionContext(sessionContext.managedContextFile, { home: resolve(this.home), taskId: owner.taskId, basis: "descriptor" });
353
+ }
312
354
  let args = [...compiled.argv];
313
355
  let session;
314
356
  if (binding.adapterId === "codex") {
@@ -365,7 +407,15 @@ export class FileRoleLaunchPlanner {
365
407
  ? undefined
366
408
  : exactTaskRuntimeDescriptorPath(this.home, runtimeDescriptor);
367
409
  if (runtimeDescriptor !== undefined && runtimeDescriptorSource !== undefined) {
368
- writeTextFileAtomically(runtimeDescriptorSource, `${serializeExactDescriptor(runtimeDescriptor)}\n`);
410
+ this.#writeExactTaskRuntimeDescriptor(runtimeDescriptor, runtimeDescriptorSource);
411
+ }
412
+ // rr13/rr26: Generate a per-Session DurableJob caller key for every
413
+ // task-scope launch, including resume. Resume/ensure may reuse a live
414
+ // host, so its candidate hash is committed by TmuxSessionHost only when
415
+ // hostCreated=true; otherwise the live process keeps its existing key.
416
+ let jobCallerKey;
417
+ if (owner.scope === "task" && (input.mode === "new" || input.mode === "resume")) {
418
+ jobCallerKey = randomBytes(32).toString("hex");
369
419
  }
370
420
  const launch = {
371
421
  command: configured.command,
@@ -379,6 +429,7 @@ export class FileRoleLaunchPlanner {
379
429
  YUI_AGENT_ID: configured.id,
380
430
  YUI_ADAPTER_ID: configured.adapterId,
381
431
  YUI_WORKSPACE: effectiveWorkspace,
432
+ ...(jobCallerKey === undefined ? {} : { YUI_JOB_CALLER_KEY: jobCallerKey }),
382
433
  ...(runtimeDescriptor === undefined
383
434
  ? {}
384
435
  : {
@@ -439,6 +490,10 @@ export class FileRoleLaunchPlanner {
439
490
  }
440
491
  };
441
492
  }
493
+ #writeExactTaskRuntimeDescriptor(descriptor, sourcePath) {
494
+ writeTextFileAtomically(sourcePath, `${serializeExactDescriptor(descriptor)}\n`);
495
+ this.#resourceRegistrar().registerExactTaskRuntimeDescriptor(descriptor, sourcePath);
496
+ }
442
497
  #selectConfiguredAgentEnvironment(source) {
443
498
  const selected = {};
444
499
  for (const agent of this.store.listConfiguredAgents()) {
@@ -0,0 +1,282 @@
1
+ import { requireIdentity, requireText, requireTimestamp, requirePositiveInteger } from "../domain/validation.js";
2
+ import { validateTaskRecordReference } from "../task/taskRecordReference.js";
3
+ export const CAPABILITY_GRANT_SCHEMA_VERSION = 1;
4
+ const IRREVERSIBILITY_RANK = {
5
+ none: 0,
6
+ reversible: 1,
7
+ irreversible: 2
8
+ };
9
+ export function createCapabilityGrant(id, taskId, input, now) {
10
+ const timestamp = now.toISOString();
11
+ return validateCapabilityGrant({
12
+ schemaVersion: CAPABILITY_GRANT_SCHEMA_VERSION,
13
+ id: validateTaskRecordReference({ taskId, localId: id }, "capabilityGrant").localId,
14
+ taskId: requireIdentity(taskId, "Task id"),
15
+ granter: requireText(input.granter, "Capability grant granter"),
16
+ scope: normalizeScope(input.scope, taskId),
17
+ actions: normalizeActions(input.actions),
18
+ parameterBounds: normalizeParameterBounds(input.parameterBounds),
19
+ ...(input.expiresAt === undefined ? {} : { expiresAt: requireTimestamp(input.expiresAt, "Capability grant expiresAt") }),
20
+ ...(input.maxUses === undefined ? {} : { maxUses: requirePositiveInteger(input.maxUses, "Capability grant maxUses") }),
21
+ usesUsed: 0,
22
+ irreversibilityCeiling: normalizeCeiling(input.irreversibilityCeiling),
23
+ createdAt: timestamp,
24
+ updatedAt: timestamp
25
+ });
26
+ }
27
+ /**
28
+ * The pure authorization decision. Every denial carries a distinct
29
+ * machine-readable reason so a release workflow can fail closed precisely.
30
+ *
31
+ * `skipUsesCheck` is used when re-attempting a step that was already running:
32
+ * the grant use was already consumed for this step before the crash, so the
33
+ * uses check is skipped to avoid stranding the workflow.
34
+ */
35
+ export function checkGrant(grant, request, now, options) {
36
+ validateCapabilityGrant(grant);
37
+ if (grant.revokedAt !== undefined) {
38
+ return { allowed: false, reason: "grant-revoked" };
39
+ }
40
+ if (grant.expiresAt !== undefined && now.getTime() > Date.parse(grant.expiresAt)) {
41
+ return { allowed: false, reason: "grant-expired" };
42
+ }
43
+ if (!options?.skipUsesCheck && grant.maxUses !== undefined && grant.usesUsed >= grant.maxUses) {
44
+ return { allowed: false, reason: "grant-uses-exhausted" };
45
+ }
46
+ if (!grant.actions.includes(request.action)) {
47
+ return { allowed: false, reason: "grant-action-not-allowed" };
48
+ }
49
+ for (const [name, allowedValues] of Object.entries(grant.parameterBounds)) {
50
+ const value = request.params?.[name];
51
+ if (value === undefined) {
52
+ return { allowed: false, reason: "grant-parameter-missing" };
53
+ }
54
+ if (!allowedValues.includes(value)) {
55
+ return { allowed: false, reason: "grant-parameter-value-not-allowed" };
56
+ }
57
+ }
58
+ const requested = IRREVERSIBILITY_RANK[request.irreversibility ?? "none"];
59
+ if (requested > IRREVERSIBILITY_RANK[grant.irreversibilityCeiling]) {
60
+ return { allowed: false, reason: "grant-irreversibility-exceeds-ceiling" };
61
+ }
62
+ return { allowed: true };
63
+ }
64
+ /** The reservation keys recorded on a grant, or an empty list for legacy grants. */
65
+ export function grantUseReservations(grant) {
66
+ return grant.useReservations ?? [];
67
+ }
68
+ /**
69
+ * Record one use against the grant, failing closed once the limit is reached.
70
+ * When `reservationKey` is supplied it is appended to the grant's durable
71
+ * reservations so a later resume can recognize this exact attempt and avoid a
72
+ * double charge. A use recorded without a key (legacy callers) leaves the
73
+ * reservation list untouched.
74
+ */
75
+ export function recordGrantUse(grant, now, reservationKey) {
76
+ validateCapabilityGrant(grant);
77
+ if (grant.maxUses !== undefined && grant.usesUsed >= grant.maxUses) {
78
+ throw new Error(`Capability grant is exhausted: ${grant.id}.`);
79
+ }
80
+ return validateCapabilityGrant({
81
+ ...grant,
82
+ usesUsed: grant.usesUsed + 1,
83
+ ...(reservationKey === undefined
84
+ ? {}
85
+ : { useReservations: Object.freeze([...grantUseReservations(grant), reservationKey]) }),
86
+ updatedAt: now.toISOString()
87
+ });
88
+ }
89
+ /**
90
+ * Revoke a grant. Re-revoking an already-revoked grant is idempotent: the
91
+ * original revocation stands and the record is returned unchanged.
92
+ */
93
+ export function revokeGrant(grant, revokedBy, now) {
94
+ validateCapabilityGrant(grant);
95
+ if (grant.revokedAt !== undefined)
96
+ return grant;
97
+ const actor = requireText(revokedBy, "Capability grant revokedBy");
98
+ const timestamp = now.toISOString();
99
+ return validateCapabilityGrant({
100
+ ...grant,
101
+ revokedAt: timestamp,
102
+ revokedBy: actor,
103
+ updatedAt: timestamp
104
+ });
105
+ }
106
+ export function validateCapabilityGrant(grant) {
107
+ if (grant.schemaVersion !== CAPABILITY_GRANT_SCHEMA_VERSION) {
108
+ throw new Error("Capability grant must use schemaVersion 1.");
109
+ }
110
+ validateTaskRecordReference({ taskId: grant.taskId, localId: grant.id }, "capabilityGrant");
111
+ requireIdentity(grant.taskId, "Task id");
112
+ requireText(grant.granter, "Capability grant granter");
113
+ validateScope(grant.scope, grant.taskId);
114
+ normalizeActions(grant.actions);
115
+ normalizeParameterBounds(grant.parameterBounds);
116
+ if (grant.expiresAt !== undefined) {
117
+ requireTimestamp(grant.expiresAt, "Capability grant expiresAt");
118
+ }
119
+ if (grant.maxUses !== undefined) {
120
+ requirePositiveInteger(grant.maxUses, "Capability grant maxUses");
121
+ }
122
+ if (!Number.isSafeInteger(grant.usesUsed) || grant.usesUsed < 0) {
123
+ throw new Error("Capability grant usesUsed must be a non-negative integer.");
124
+ }
125
+ if (grant.maxUses !== undefined && grant.usesUsed > grant.maxUses) {
126
+ throw new Error("Capability grant usesUsed cannot exceed maxUses.");
127
+ }
128
+ if (grant.useReservations !== undefined) {
129
+ if (!Array.isArray(grant.useReservations)) {
130
+ throw new Error("Capability grant useReservations must be an array.");
131
+ }
132
+ for (const key of grant.useReservations) {
133
+ if (typeof key !== "string" || key.length === 0) {
134
+ throw new Error("Capability grant useReservations must be non-empty strings.");
135
+ }
136
+ }
137
+ if (new Set(grant.useReservations).size !== grant.useReservations.length) {
138
+ throw new Error("Capability grant useReservations must be unique.");
139
+ }
140
+ if (grant.useReservations.length > grant.usesUsed) {
141
+ throw new Error("Capability grant useReservations cannot outnumber usesUsed.");
142
+ }
143
+ }
144
+ normalizeCeiling(grant.irreversibilityCeiling);
145
+ if ((grant.revokedAt === undefined) !== (grant.revokedBy === undefined)) {
146
+ throw new Error("Capability grant revocation requires both revokedAt and revokedBy.");
147
+ }
148
+ if (grant.revokedAt !== undefined) {
149
+ requireTimestamp(grant.revokedAt, "Capability grant revokedAt");
150
+ requireText(grant.revokedBy, "Capability grant revokedBy");
151
+ }
152
+ requireTimestamp(grant.createdAt, "Capability grant createdAt");
153
+ requireTimestamp(grant.updatedAt, "Capability grant updatedAt");
154
+ return grant;
155
+ }
156
+ function normalizeCeiling(value) {
157
+ if (value === undefined)
158
+ return "none";
159
+ if (!Object.hasOwn(IRREVERSIBILITY_RANK, value)) {
160
+ throw new Error(`Capability grant irreversibility ceiling is invalid: ${String(value)}.`);
161
+ }
162
+ return value;
163
+ }
164
+ function normalizeActions(actions) {
165
+ if (!Array.isArray(actions) || actions.length === 0) {
166
+ throw new Error("Capability grant actions must be a non-empty array.");
167
+ }
168
+ const normalized = actions.map((action) => requireText(action, "Capability grant action"));
169
+ if (new Set(normalized).size !== normalized.length) {
170
+ throw new Error("Capability grant actions must be unique.");
171
+ }
172
+ return Object.freeze(normalized);
173
+ }
174
+ function normalizeParameterBounds(bounds) {
175
+ if (bounds === undefined)
176
+ return Object.freeze({});
177
+ if (typeof bounds !== "object" || bounds === null || Array.isArray(bounds)) {
178
+ throw new Error("Capability grant parameterBounds must be an object.");
179
+ }
180
+ const normalized = {};
181
+ for (const [name, values] of Object.entries(bounds)) {
182
+ const key = requireIdentity(name, "Capability grant parameter");
183
+ if (!Array.isArray(values) || values.length === 0) {
184
+ throw new Error(`Capability grant parameter bound must list allowed values: ${name}.`);
185
+ }
186
+ const allowed = values.map((value) => requireText(value, `Capability grant parameter ${name} value`));
187
+ if (new Set(allowed).size !== allowed.length) {
188
+ throw new Error(`Capability grant parameter bound values must be unique: ${name}.`);
189
+ }
190
+ normalized[key] = Object.freeze(allowed);
191
+ }
192
+ return Object.freeze(normalized);
193
+ }
194
+ function normalizeScope(scope, taskId) {
195
+ if (scope === undefined)
196
+ return Object.freeze({ taskId });
197
+ validateScope(scope, taskId);
198
+ const normalized = {
199
+ ...(scope.taskId === undefined ? {} : { taskId: requireIdentity(scope.taskId, "Capability grant scope taskId") }),
200
+ ...(scope.projectIds === undefined || scope.projectIds.length === 0
201
+ ? {}
202
+ : { projectIds: normalizeScopeIdentities(scope.projectIds, "Capability grant scope Project") }),
203
+ ...(scope.repositories === undefined || scope.repositories.length === 0
204
+ ? {}
205
+ : { repositories: normalizeRepositories(scope.repositories) }),
206
+ ...(scope.packages === undefined || scope.packages.length === 0
207
+ ? {}
208
+ : { packages: normalizeScopeText(scope.packages, "Capability grant scope package") }),
209
+ ...(scope.homePath === undefined ? {} : { homePath: requireText(scope.homePath, "Capability grant scope homePath") })
210
+ };
211
+ if (!hasScopeSelector(normalized)) {
212
+ return Object.freeze({ taskId });
213
+ }
214
+ return Object.freeze(normalized);
215
+ }
216
+ function validateScope(scope, taskId) {
217
+ if (typeof scope !== "object" || scope === null || Array.isArray(scope)) {
218
+ throw new Error("Capability grant scope must be an object.");
219
+ }
220
+ if (scope.taskId !== undefined) {
221
+ requireIdentity(scope.taskId, "Capability grant scope taskId");
222
+ }
223
+ if (scope.projectIds !== undefined) {
224
+ normalizeScopeIdentities(scope.projectIds, "Capability grant scope Project");
225
+ }
226
+ if (scope.repositories !== undefined) {
227
+ normalizeRepositories(scope.repositories);
228
+ }
229
+ if (scope.packages !== undefined) {
230
+ normalizeScopeText(scope.packages, "Capability grant scope package");
231
+ }
232
+ if (scope.homePath !== undefined) {
233
+ requireText(scope.homePath, "Capability grant scope homePath");
234
+ }
235
+ if (!hasScopeSelector(scope) && scope.taskId === undefined) {
236
+ throw new Error(`Capability grant scope requires at least one selector: ${taskId}.`);
237
+ }
238
+ }
239
+ function hasScopeSelector(scope) {
240
+ return scope.taskId !== undefined
241
+ || (scope.projectIds?.length ?? 0) > 0
242
+ || (scope.repositories?.length ?? 0) > 0
243
+ || (scope.packages?.length ?? 0) > 0
244
+ || scope.homePath !== undefined;
245
+ }
246
+ function normalizeScopeIdentities(values, label) {
247
+ if (!Array.isArray(values))
248
+ throw new Error(`${label} ids must be an array.`);
249
+ const normalized = values.map((value) => requireIdentity(value, label));
250
+ if (new Set(normalized).size !== normalized.length) {
251
+ throw new Error(`${label} ids must be unique.`);
252
+ }
253
+ return Object.freeze(normalized);
254
+ }
255
+ function normalizeScopeText(values, label) {
256
+ if (!Array.isArray(values))
257
+ throw new Error(`${label}s must be an array.`);
258
+ const normalized = values.map((value) => requireText(value, label));
259
+ if (new Set(normalized).size !== normalized.length) {
260
+ throw new Error(`${label}s must be unique.`);
261
+ }
262
+ return Object.freeze(normalized);
263
+ }
264
+ function normalizeRepositories(repositories) {
265
+ if (!Array.isArray(repositories)) {
266
+ throw new Error("Capability grant scope repositories must be an array.");
267
+ }
268
+ const normalized = repositories.map((repository) => {
269
+ if (typeof repository !== "object" || repository === null) {
270
+ throw new Error("Capability grant scope repository is invalid.");
271
+ }
272
+ return Object.freeze({
273
+ owner: requireText(repository.owner, "Capability grant scope repository owner"),
274
+ name: requireText(repository.name, "Capability grant scope repository name")
275
+ });
276
+ });
277
+ const keys = new Set(normalized.map(({ owner, name }) => `${owner}/${name}`));
278
+ if (keys.size !== normalized.length) {
279
+ throw new Error("Capability grant scope repositories must be unique.");
280
+ }
281
+ return Object.freeze(normalized);
282
+ }
@@ -1,16 +1,20 @@
1
1
  import { normalizedUniqueText, requireIdentity, requireText, requireTimestamp } from "../domain/validation.js";
2
2
  import { validateTaskRecordReference } from "../task/taskRecordReference.js";
3
+ import { validateChangeSetManifest } from "./changeSetManifest.js";
3
4
  export function createWorkItemChangeSet(input, now) {
4
5
  return validateChangeSet({
5
- schemaVersion: 2,
6
+ schemaVersion: 3,
6
7
  ...input,
7
8
  changedPaths: [...input.changedPaths],
9
+ ...(input.manifest === undefined
10
+ ? {}
11
+ : { manifest: validateChangeSetManifest(input.manifest) }),
8
12
  createdAt: now.toISOString()
9
13
  });
10
14
  }
11
15
  export function validateChangeSet(changeSet) {
12
- if (changeSet.schemaVersion !== 2)
13
- throw new Error("ChangeSet must use schemaVersion 2.");
16
+ if (changeSet.schemaVersion !== 3)
17
+ throw new Error("ChangeSet must use schemaVersion 3.");
14
18
  validateTaskRecordReference({ taskId: changeSet.taskId, localId: changeSet.id }, "changeSet");
15
19
  validateTaskRecordReference({
16
20
  taskId: changeSet.taskId,
@@ -24,6 +28,15 @@ export function validateChangeSet(changeSet) {
24
28
  }
25
29
  requireText(changeSet.branch, "ChangeSet branch");
26
30
  normalizedUniqueText(changeSet.changedPaths, "ChangeSet path");
31
+ if (changeSet.manifest !== undefined) {
32
+ const manifest = validateChangeSetManifest(changeSet.manifest);
33
+ const changed = new Set(changeSet.changedPaths);
34
+ for (const deleted of manifest.deletedPaths) {
35
+ if (!changed.has(deleted)) {
36
+ throw new Error(`ChangeSet manifest deleted path is not a changed path: ${deleted}.`);
37
+ }
38
+ }
39
+ }
27
40
  requireTimestamp(changeSet.createdAt, "ChangeSet createdAt");
28
41
  return changeSet;
29
42
  }
@@ -0,0 +1,46 @@
1
+ import { normalizedUniqueText, requireText } from "../domain/validation.js";
2
+ export const CHANGE_SET_MANIFEST_SCHEMA_VERSION = 1;
3
+ export const CHANGE_SET_MANIFEST_TAGS = [
4
+ "contract",
5
+ "schema",
6
+ "migration",
7
+ "command",
8
+ "test",
9
+ "snapshot",
10
+ "package",
11
+ "deletion"
12
+ ];
13
+ export function createChangeSetManifest(input) {
14
+ return validateChangeSetManifest({
15
+ schemaVersion: CHANGE_SET_MANIFEST_SCHEMA_VERSION,
16
+ tags: [...input.tags],
17
+ deletedPaths: [...input.deletedPaths],
18
+ evidenceRefs: [...(input.evidenceRefs ?? [])],
19
+ ...(input.targetRef === undefined ? {} : { targetRef: input.targetRef })
20
+ });
21
+ }
22
+ export function validateChangeSetManifest(manifest) {
23
+ if (manifest.schemaVersion !== CHANGE_SET_MANIFEST_SCHEMA_VERSION) {
24
+ throw new Error("ChangeSet manifest must use schemaVersion 1.");
25
+ }
26
+ if (!Array.isArray(manifest.tags)) {
27
+ throw new Error("ChangeSet manifest tags must be an array.");
28
+ }
29
+ for (const tag of manifest.tags) {
30
+ if (!CHANGE_SET_MANIFEST_TAGS.includes(tag)) {
31
+ throw new Error(`ChangeSet manifest tag is invalid: ${String(tag)}.`);
32
+ }
33
+ }
34
+ const tags = normalizedUniqueText(manifest.tags, "ChangeSet manifest tag");
35
+ const deletedPaths = normalizedUniqueText(manifest.deletedPaths, "ChangeSet manifest deleted path");
36
+ const evidenceRefs = normalizedUniqueText(manifest.evidenceRefs ?? [], "ChangeSet manifest evidence reference");
37
+ return {
38
+ schemaVersion: CHANGE_SET_MANIFEST_SCHEMA_VERSION,
39
+ tags,
40
+ deletedPaths,
41
+ evidenceRefs,
42
+ ...(manifest.targetRef === undefined ? {} : {
43
+ targetRef: requireText(manifest.targetRef, "ChangeSet manifest target ref")
44
+ })
45
+ };
46
+ }