@zq-silk/yui 0.2.0 → 0.4.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.
- package/ARCHITECTURE.md +603 -133
- package/README.md +806 -31
- package/dist/agent/agent.js +2 -1
- package/dist/agent/argumentPolicy.js +3 -1
- package/dist/agent/launchEnvironment.js +106 -0
- package/dist/agent/managedRuntimeEnvironment.js +34 -0
- package/dist/brief/taskBrief.js +11 -1
- package/dist/cli/agentConfigurationPicker.js +287 -0
- package/dist/cli/commandCatalog.js +488 -60
- package/dist/cli/completion.js +146 -22
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli/interactionCandidates.js +53 -15
- package/dist/cli/interactionPolicy.js +267 -30
- package/dist/cli/interactiveSelection.js +6 -2
- package/dist/cli/invocationRouter.js +5 -1
- package/dist/cli/operatorWizard.js +87 -0
- package/dist/cli/roleOptionCatalog.js +1 -0
- package/dist/cli/roleWizard.js +185 -21
- package/dist/cli/updateCommand.js +62 -19
- package/dist/cli/updateOrchestrator.js +539 -0
- package/dist/cli/updatePorts.js +1119 -0
- package/dist/cli/upgradeCommand.js +112 -0
- package/dist/cli.js +1420 -86
- package/dist/commands/agentCommands.js +146 -3
- package/dist/commands/configCommands.js +126 -0
- package/dist/commands/controllerCommands.js +365 -0
- package/dist/commands/globalRoleCommands.js +168 -126
- package/dist/commands/jobCommands.js +18 -8
- package/dist/commands/operatorCommands.js +159 -9
- package/dist/commands/profileCommands.js +203 -0
- package/dist/commands/projectCommands.js +650 -0
- package/dist/commands/roleConfiguration.js +85 -24
- package/dist/commands/roleRuntimeGuard.js +12 -0
- package/dist/commands/roleSkillValidation.js +47 -0
- package/dist/commands/taskActor.js +127 -0
- package/dist/commands/taskCommands.js +4201 -313
- package/dist/commands/taskCompletionGate.js +131 -0
- package/dist/commands/taskContextCommand.js +244 -30
- package/dist/commands/taskInputCommands.js +177 -59
- package/dist/commands/taskIntegrationCommands.js +303 -0
- package/dist/commands/taskOverviewCommand.js +363 -0
- package/dist/commands/taskRoleRuntimeStatus.js +125 -19
- package/dist/commands/textInput.js +15 -0
- package/dist/completion/completionInstaller.js +26 -22
- package/dist/config/yuiConfig.js +4 -3
- package/dist/context/dispatchContext.js +90 -38
- package/dist/context/roleSessionContext.js +119 -0
- package/dist/controller/claudeLifecycleHook.js +203 -0
- package/dist/controller/clientRuntime.js +408 -56
- package/dist/controller/codexLifecycleHook.js +108 -0
- package/dist/controller/controller.js +1089 -32
- package/dist/controller/domainIdentity.js +505 -0
- package/dist/controller/ephemeralResourceReaper.js +131 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
- package/dist/controller/providerHookRunFence.js +127 -0
- package/dist/controller/resourceCleanupLinux.js +286 -0
- package/dist/controller/resourceInventory.js +531 -0
- package/dist/controller/resourceInventoryLinux.js +610 -0
- package/dist/controller/runtime.js +629 -10
- package/dist/controller/runtimeEventInbox.js +564 -0
- package/dist/controller/runtimeEventProcessor.js +248 -0
- package/dist/controller/runtimeLaunchCoordinator.js +477 -0
- package/dist/controller/sessionNotify.js +121 -78
- package/dist/coordination/deadlineScheduler.js +15 -0
- package/dist/coordination/mailboxScheduler.js +108 -0
- package/dist/coordination/workMailbox.js +329 -0
- package/dist/coordination/workMailboxQueue.js +86 -0
- package/dist/core/controllerClient.js +19 -5
- package/dist/core/controllerEndpoint.js +37 -0
- package/dist/core/controllerServer.js +218 -10
- package/dist/core/protocol.js +6 -2
- package/dist/decision/decision.js +2 -1
- package/dist/doctor/doctor.js +681 -32
- package/dist/domain/validation.js +53 -0
- package/dist/errors/cliError.js +5 -3
- package/dist/event/taskEvent.js +7 -3
- package/dist/execution/codexThreadNaming.js +160 -0
- package/dist/execution/executionGroup.js +579 -0
- package/dist/executor/agentAdapter.js +255 -40
- package/dist/executor/agentConfigurationCatalog.js +326 -0
- package/dist/executor/agentConfigurationProbe.js +506 -0
- package/dist/executor/agentExecutor.js +625 -10
- package/dist/executor/codexConfigConflict.js +290 -0
- package/dist/executor/effectiveLaunch.js +340 -0
- package/dist/executor/executorRegistry.js +238 -36
- package/dist/executor/fileRoleLaunchPlanner.js +550 -40
- package/dist/executor/turnCompletion.js +126 -0
- package/dist/input/inputRequest.js +30 -9
- package/dist/integration/changeSet.js +36 -0
- package/dist/integration/checkResult.js +24 -0
- package/dist/integration/gitIntegrationService.js +695 -0
- package/dist/integration/integrationAttempt.js +142 -0
- package/dist/interaction/operatorPresentation.js +96 -0
- package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
- package/dist/lifecycle/exactRunTerminalization.js +572 -0
- package/dist/lifecycle/providerLifecycleMapping.js +190 -0
- package/dist/lifecycle/taskRoleSessionReset.js +124 -0
- package/dist/message/message.js +23 -7
- package/dist/milestone/milestone.js +2 -1
- package/dist/operator/operatorSessionHistory.js +124 -0
- package/dist/output/agentConfigurationPresentation.js +43 -0
- package/dist/output/rolePresentation.js +34 -10
- package/dist/output/terminal.js +8 -0
- package/dist/output/timePresentation.js +55 -0
- package/dist/profile/agentProfile.js +128 -0
- package/dist/repository/gitWorkspace.js +578 -24
- package/dist/repository/project.js +213 -0
- package/dist/repository/taskWorkspaceCoordinator.js +392 -0
- package/dist/repository/taskWorkspacePreparer.js +1688 -191
- package/dist/review/reviewConfig.js +11 -0
- package/dist/review/reviewRound.js +399 -0
- package/dist/review/taskFinalReviewContract.js +90 -0
- package/dist/role/role.js +124 -23
- package/dist/run/agentRun.js +155 -12
- package/dist/run/runIdentity.js +82 -0
- package/dist/runtime/exactControlPlane.js +472 -0
- package/dist/runtime/index.js +8 -0
- package/dist/runtime/lifecycleReservation.js +38 -0
- package/dist/runtime/ports.js +11 -0
- package/dist/runtime/preallocatedNativeSession.js +13 -0
- package/dist/runtime/promptEnvelope.js +30 -0
- package/dist/runtime/runtimeBinding.js +31 -0
- package/dist/runtime/runtimeOwner.js +14 -0
- package/dist/runtime/sessionLaunchRequest.js +62 -0
- package/dist/runtime/sessionTitle.js +54 -0
- package/dist/runtime/taskRuntimeIsolation.js +643 -0
- package/dist/runtime/tmuxAdapters.js +315 -0
- package/dist/runtime/turnCompletion.js +3 -0
- package/dist/runtime/validation.js +23 -0
- package/dist/scheduler/activeRoleRunDelivery.js +342 -32
- package/dist/scheduler/activeTaskProgress.js +63 -0
- package/dist/scheduler/leaderFailure.js +2 -1
- package/dist/scheduler/leaderWakeupProcessor.js +307 -66
- package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
- package/dist/scheduler/operatorNotification.js +44 -2
- package/dist/scheduler/ports.js +28 -1
- package/dist/scheduler/roleRunLiveness.js +131 -25
- package/dist/scheduler/roleRunStall.js +951 -0
- package/dist/scheduler/taskExecutionProjection.js +544 -0
- package/dist/scheduler/wakeupQueue.js +3 -0
- package/dist/setup/setupCommand.js +302 -52
- package/dist/storage/compatibleTaskStore.js +102 -0
- package/dist/storage/migration/baseline.js +78 -0
- package/dist/storage/migration/classifier.js +51 -0
- package/dist/storage/migration/compatibleCodec.js +53 -0
- package/dist/storage/migration/engine.js +147 -0
- package/dist/storage/migration/index.js +33 -0
- package/dist/storage/migration/planner.js +154 -0
- package/dist/storage/migration/productionRegistry.js +486 -0
- package/dist/storage/migration/registry.js +169 -0
- package/dist/storage/migration/report.js +54 -0
- package/dist/storage/migration/types.js +31 -0
- package/dist/storage/storageSchema.js +147 -123
- package/dist/storage/storageVersions.js +11 -0
- package/dist/storage/taskStore.js +1793 -197
- package/dist/storage/upgrade/homeClassification.js +156 -0
- package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
- package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
- package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
- package/dist/storage/upgrade/recordVersionScan.js +176 -0
- package/dist/storage/upgrade/recordVersions.js +159 -0
- package/dist/storage/upgrade/switchProgress.js +80 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
- package/dist/storage/upgrade/upgradeReceipt.js +161 -0
- package/dist/storage/upgradeCoordination.js +186 -0
- package/dist/storage/upgradeFence.js +366 -0
- package/dist/task/task.js +132 -26
- package/dist/task/taskRecordReference.js +66 -0
- package/dist/tmux/commandExecutor.js +75 -2
- package/dist/tmux/tmuxManager.js +747 -49
- package/dist/version.js +23 -0
- package/dist/web/assets/assetManifest.js +62 -0
- package/dist/web/assets/client/app.js +631 -0
- package/dist/web/assets/client/components.js +605 -0
- package/dist/web/assets/client/dom.js +14 -0
- package/dist/web/assets/client/format.js +28 -0
- package/dist/web/assets/client/i18n.js +494 -0
- package/dist/web/assets/client/markdown.js +114 -0
- package/dist/web/assets/client/theme.js +32 -0
- package/dist/web/assets/client/view.js +458 -0
- package/dist/web/assets/fontData.js +12 -0
- package/dist/web/assets/fonts.js +12 -0
- package/dist/web/assets/shell.js +114 -0
- package/dist/web/assets/styles/cards.js +135 -0
- package/dist/web/assets/styles/layout.js +47 -0
- package/dist/web/assets/styles/markdown.js +29 -0
- package/dist/web/assets/styles/responsive.js +39 -0
- package/dist/web/assets/styles/tokens.js +101 -0
- package/dist/web/assets/styles/widgets.js +147 -0
- package/dist/web/tmuxWebTerminal.js +158 -0
- package/dist/web/webServer.js +463 -0
- package/dist/web/webSnapshot.js +148 -0
- package/dist/workItem/workItem.js +642 -23
- package/dist/workspace/gitChangeSetCapture.js +86 -0
- package/dist/workspace/workItemChangeSetManager.js +445 -0
- package/dist/worktree/managedWorkspace.js +202 -0
- package/docs/task-local-identity.md +62 -0
- package/i18n/README.zh-CN.md +406 -31
- package/package.json +10 -2
- package/skills/yui-leader/SKILL.md +601 -39
- package/skills/yui-operator/SKILL.md +255 -34
- package/skills/yui-reviewer/SKILL.md +57 -0
- package/skills/yui-worker/SKILL.md +214 -17
- package/dist/commands/repositoryCommands.js +0 -86
- package/dist/operator/operatorContext.js +0 -66
- package/dist/repository/repository.js +0 -55
- package/dist/scheduler/archivedTaskRuntime.js +0 -12
- package/dist/worktree/roleWorkspace.js +0 -62
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The upgrade admission fence.
|
|
3
|
+
*
|
|
4
|
+
* A fence is a small marker file inside a Yui Home. While it exists and is owned
|
|
5
|
+
* by a live process other than the caller, every authoritative writer — a
|
|
6
|
+
* baseline `yui` CLI invocation *and* the per-home Controller alike — refuses to
|
|
7
|
+
* begin a new write. This is the mechanism by which `yui upgrade`/`yui update`
|
|
8
|
+
* stop new writes before quiescing and switching the store, without a broad
|
|
9
|
+
* process kill or a TTL/idle heuristic.
|
|
10
|
+
*
|
|
11
|
+
* The fence is honored at the single storage write choke point
|
|
12
|
+
* (`FileTaskStore` commit), so both the CLI and the Controller — which mutate
|
|
13
|
+
* through the same store — observe it. Because the check lives in that shared
|
|
14
|
+
* path, every writer built from this release forward enforces it; it cannot
|
|
15
|
+
* retroactively bind an already-installed older binary, so the upgrade
|
|
16
|
+
* orchestrator additionally drains the Controller and fails closed on any live
|
|
17
|
+
* writer rather than relying on the fence alone. It never blocks reads, and it
|
|
18
|
+
* never blocks the process that placed it (so the upgrade orchestrator can
|
|
19
|
+
* re-pin the revision under the write lock). A fence whose owner process is gone
|
|
20
|
+
* is stale and is reclaimed, mirroring the storage lock's dead-owner reclaim.
|
|
21
|
+
*/
|
|
22
|
+
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
/** Fence marker location, beside the Controller discovery file under `runtime/`. */
|
|
25
|
+
export const UPGRADE_FENCE_FILE = "runtime/upgrade.fence";
|
|
26
|
+
/** Thrown when a writer is refused because an upgrade fence is in place. */
|
|
27
|
+
export class UpgradeFenceError extends Error {
|
|
28
|
+
reason;
|
|
29
|
+
constructor(reason) {
|
|
30
|
+
super("Yui storage is fenced for an in-progress upgrade; new writes are refused "
|
|
31
|
+
+ `(${reason}). Wait for the upgrade to finish, or if no upgrade is `
|
|
32
|
+
+ "running remove the stale fence and retry.");
|
|
33
|
+
this.name = "UpgradeFenceError";
|
|
34
|
+
this.reason = reason;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function fencePath(home) {
|
|
38
|
+
return join(home, UPGRADE_FENCE_FILE);
|
|
39
|
+
}
|
|
40
|
+
/** Read the fence file's raw bytes at an exact path, or `null` when absent. */
|
|
41
|
+
function readFenceRaw(path) {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(path, "utf8");
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (isEnoent(error))
|
|
47
|
+
return null;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse raw fence bytes into a record. A well-formed fence yields its fields; a
|
|
53
|
+
* malformed/unparseable fence yields a sentinel `ownerPid: -1` "malformed fence"
|
|
54
|
+
* so callers treat it as an undeterminable (fail-closed) live fence, never as
|
|
55
|
+
* absent or reclaimable.
|
|
56
|
+
*/
|
|
57
|
+
function parseFence(raw) {
|
|
58
|
+
try {
|
|
59
|
+
const value = JSON.parse(raw);
|
|
60
|
+
if (value.schemaVersion === 1
|
|
61
|
+
&& Number.isInteger(value.ownerPid)
|
|
62
|
+
&& typeof value.reason === "string"
|
|
63
|
+
&& typeof value.createdAt === "string") {
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
ownerPid: value.ownerPid,
|
|
67
|
+
reason: value.reason,
|
|
68
|
+
createdAt: value.createdAt
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// fall through to the malformed sentinel.
|
|
74
|
+
}
|
|
75
|
+
return { schemaVersion: 1, ownerPid: -1, reason: "malformed fence", createdAt: "" };
|
|
76
|
+
}
|
|
77
|
+
/** Read the current fence record, or `null` when none is present or it is unreadable. */
|
|
78
|
+
export function readUpgradeFence(home) {
|
|
79
|
+
const raw = readFenceRaw(fencePath(home));
|
|
80
|
+
if (raw === null)
|
|
81
|
+
return null;
|
|
82
|
+
return parseFence(raw);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Refuse a write when a live upgrade fence owned by another process is present.
|
|
86
|
+
* A fence owned by the current process, or by a process that is gone, never
|
|
87
|
+
* blocks: the placer may re-pin under the lock, and a stale (dead-owner) fence is
|
|
88
|
+
* reclaimed. A stale fence is only cleared for the caller once the reclaim is
|
|
89
|
+
* PROVEN complete — if it cannot be cleared, this fails closed rather than
|
|
90
|
+
* falsely reporting the Home writable (R2-F4).
|
|
91
|
+
*/
|
|
92
|
+
export function assertHomeWritable(home, callerPid = process.pid) {
|
|
93
|
+
const path = fencePath(home);
|
|
94
|
+
const raw = readFenceRaw(path);
|
|
95
|
+
if (raw === null)
|
|
96
|
+
return;
|
|
97
|
+
const fence = parseFence(raw);
|
|
98
|
+
if (fence.ownerPid === callerPid)
|
|
99
|
+
return;
|
|
100
|
+
if (fence.ownerPid > 0 && !processIsAlive(fence.ownerPid)) {
|
|
101
|
+
// Stale fence from a crashed upgrade: reclaim it with the crash-safe atomic
|
|
102
|
+
// rename-aside (F4/R2-F4), then RE-VERIFY. We only permit the write if the
|
|
103
|
+
// admission path is now clear of a blocking fence:
|
|
104
|
+
// - absent / owned by us / owned by a still-dead pid -> writable, and
|
|
105
|
+
// - a LIVE foreign owner that took the freed slot -> refuse.
|
|
106
|
+
// If the stale fence is somehow still present (reclaim could not move it), we
|
|
107
|
+
// do NOT return writable — fail closed.
|
|
108
|
+
reclaimStaleFence(path, raw);
|
|
109
|
+
const afterRaw = readFenceRaw(path);
|
|
110
|
+
if (afterRaw === null)
|
|
111
|
+
return; // reclaimed: path is free.
|
|
112
|
+
const after = parseFence(afterRaw);
|
|
113
|
+
if (after.ownerPid === callerPid)
|
|
114
|
+
return;
|
|
115
|
+
if (after.ownerPid > 0 && !processIsAlive(after.ownerPid) && afterRaw !== raw) {
|
|
116
|
+
// A different dead-owner fence appeared (a racer's crashed attempt); it is
|
|
117
|
+
// itself reclaimable, so the Home is not live-fenced. Permit.
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Either the SAME stale fence is still there (reclaim did not complete) or a
|
|
121
|
+
// live/undeterminable owner now holds it: fail closed, never falsely writable.
|
|
122
|
+
throw new UpgradeFenceError(after.reason);
|
|
123
|
+
}
|
|
124
|
+
throw new UpgradeFenceError(fence.reason);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Place an upgrade fence owned by the current process and return a release
|
|
128
|
+
* handle. The handle is idempotent and only removes a fence this process owns.
|
|
129
|
+
*
|
|
130
|
+
* Acquisition is a SINGLE atomic `O_CREAT | O_EXCL` create (P2-5): the kernel
|
|
131
|
+
* guarantees exactly one of any number of concurrent entrants wins that create,
|
|
132
|
+
* so there is no check-then-write window in which two upgraders both believe
|
|
133
|
+
* they acquired. A loser reads the existing fence and either:
|
|
134
|
+
* - re-enters (it already owns the fence — idempotent), or
|
|
135
|
+
* - reclaims a PROVABLY-DEAD owner's stale fence and retries the atomic create
|
|
136
|
+
* once (a dead owner cannot be a live competitor), or
|
|
137
|
+
* - fails closed with {@link UpgradeFenceError} for a live foreign owner or an
|
|
138
|
+
* undeterminable/malformed fence.
|
|
139
|
+
* There is no lease, heartbeat, or multi-round negotiation — just the one atomic
|
|
140
|
+
* create plus a bounded dead-owner reclaim.
|
|
141
|
+
*/
|
|
142
|
+
export function placeUpgradeFence(home, options) {
|
|
143
|
+
const ownerPid = options.ownerPid ?? process.pid;
|
|
144
|
+
const fence = {
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
ownerPid,
|
|
147
|
+
reason: options.reason,
|
|
148
|
+
createdAt: options.createdAt
|
|
149
|
+
};
|
|
150
|
+
const content = `${JSON.stringify(fence, null, 2)}\n`;
|
|
151
|
+
const path = fencePath(home);
|
|
152
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
153
|
+
// At most a bounded number of attempts: each reclaim of a dead owner's stale
|
|
154
|
+
// fence is followed by one more atomic-create try. A fresh race is settled by
|
|
155
|
+
// the first atomic create; a live foreign owner fails immediately.
|
|
156
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
157
|
+
if (tryCreateFenceExclusive(path, content)) {
|
|
158
|
+
fsyncDirectory(dirname(path));
|
|
159
|
+
return makeFenceRelease(home, ownerPid);
|
|
160
|
+
}
|
|
161
|
+
// The atomic create lost: a fence already exists. Decide purely from its
|
|
162
|
+
// owner — never by overwriting, which would reintroduce the race.
|
|
163
|
+
const rawExisting = readFenceRaw(path);
|
|
164
|
+
if (rawExisting === null)
|
|
165
|
+
continue; // vanished between create and read; retry.
|
|
166
|
+
const existing = parseFence(rawExisting);
|
|
167
|
+
if (existing.ownerPid === ownerPid) {
|
|
168
|
+
// This process already owns the fence: idempotent re-entry.
|
|
169
|
+
return makeFenceRelease(home, ownerPid);
|
|
170
|
+
}
|
|
171
|
+
if (existing.ownerPid > 0 && !processIsAlive(existing.ownerPid)) {
|
|
172
|
+
// Provably-dead owner: reclaim the stale fence with a crash-safe atomic
|
|
173
|
+
// rename-aside (F4). It moves ONLY the exact stale inode out of the
|
|
174
|
+
// admission path — a racer that replaced it with a live fence is preserved,
|
|
175
|
+
// and no persistent coordination lock is left to orphan and block future
|
|
176
|
+
// reclaims. Then retry the atomic create against the freed path.
|
|
177
|
+
reclaimStaleFence(path, rawExisting);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// A live foreign owner, or an undeterminable/malformed fence (ownerPid <= 0):
|
|
181
|
+
// another upgrade holds this Home. Fail closed with no coordination protocol.
|
|
182
|
+
throw new UpgradeFenceError(existing.reason);
|
|
183
|
+
}
|
|
184
|
+
// Still contended after the bounded reclaim retries: fail closed rather than spin.
|
|
185
|
+
throw new UpgradeFenceError(readUpgradeFence(home)?.reason ?? "upgrade fence contended");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Reclaim a stale (dead-owner) fence: delete the fence file at `path` only if its
|
|
189
|
+
* bytes still exactly equal `expectedRaw`, under a short critical section that is
|
|
190
|
+
* itself CRASH-SAFE (R2-F4).
|
|
191
|
+
*
|
|
192
|
+
* The exclusion is a `mkdir` lock — but, mirroring the storage lock's
|
|
193
|
+
* `reclaimDeadLock`, the lock records its owner pid and is itself reclaimable, so
|
|
194
|
+
* a holder that crashes mid-critical-section cannot permanently orphan it and
|
|
195
|
+
* strand admission. On contention we first try to reclaim an orphaned lock (dead
|
|
196
|
+
* owner, or older than a small age bound), then retry; if the lock is genuinely
|
|
197
|
+
* held by a live process we simply return (that live reclaimer will finish, and
|
|
198
|
+
* the caller's next O_EXCL create settles the race).
|
|
199
|
+
*
|
|
200
|
+
* Correctness of the delete: inside the lock we RE-READ the fence and delete only
|
|
201
|
+
* if the bytes are still the exact stale ones, so a racer that O_EXCL-created a
|
|
202
|
+
* fresh live fence at `path` between our observe and the delete is never
|
|
203
|
+
* clobbered. The single O_EXCL create in `placeUpgradeFence` remains the sole
|
|
204
|
+
* winner-decider, so a reclaim never grants ownership by itself.
|
|
205
|
+
*/
|
|
206
|
+
function reclaimStaleFence(path, expectedRaw) {
|
|
207
|
+
const reclaimLock = `${path}.reclaim.lock`;
|
|
208
|
+
const ownerFile = join(reclaimLock, "owner");
|
|
209
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
210
|
+
try {
|
|
211
|
+
mkdirSync(reclaimLock); // atomic; EEXIST if another reclaimer holds it.
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (!isEexist(error))
|
|
215
|
+
throw error;
|
|
216
|
+
// Contended: reclaim the lock only if it is orphaned (dead owner or too
|
|
217
|
+
// old), then retry once. A live-held lock means another reclaimer is
|
|
218
|
+
// actively working — leave it and let the O_EXCL create settle the race.
|
|
219
|
+
if (reclaimOrphanedLock(reclaimLock, ownerFile))
|
|
220
|
+
continue;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
writeFenceLockOwner(ownerFile);
|
|
225
|
+
// Re-read UNDER the lock; delete only if still the exact stale bytes.
|
|
226
|
+
const current = readFenceRaw(path);
|
|
227
|
+
if (current !== null && current === expectedRaw) {
|
|
228
|
+
rmSync(path, { force: true });
|
|
229
|
+
fsyncDirectory(dirname(path));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Small age bound below which an orphaned-looking lock is given the benefit of the doubt. */
|
|
239
|
+
const RECLAIM_LOCK_MIN_AGE_MS = 1_000;
|
|
240
|
+
/**
|
|
241
|
+
* Reclaim a reclaim-lock directory ONLY when it is provably orphaned: its owner
|
|
242
|
+
* pid is dead (or unreadable) AND it is older than a small age bound (so a lock
|
|
243
|
+
* whose owner just `mkdir`ed but has not yet written its pid is not stolen).
|
|
244
|
+
* Returns true when it removed the lock (caller should retry), false otherwise.
|
|
245
|
+
*/
|
|
246
|
+
function reclaimOrphanedLock(reclaimLock, ownerFile) {
|
|
247
|
+
try {
|
|
248
|
+
const age = Date.now() - statSync(reclaimLock).mtimeMs;
|
|
249
|
+
if (age < RECLAIM_LOCK_MIN_AGE_MS)
|
|
250
|
+
return false; // too fresh; owner may be mid-acquire.
|
|
251
|
+
let pid;
|
|
252
|
+
try {
|
|
253
|
+
pid = Number.parseInt(readFileSync(ownerFile, "utf8"), 10);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
pid = null; // no/unreadable owner on an old lock: treat as orphaned.
|
|
257
|
+
}
|
|
258
|
+
if (pid !== null && Number.isInteger(pid) && processIsAlive(pid))
|
|
259
|
+
return false; // live holder.
|
|
260
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
// ENOENT: the lock vanished (holder released it) — retry the mkdir.
|
|
265
|
+
return isEnoent(error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function writeFenceLockOwner(ownerFile) {
|
|
269
|
+
let descriptor;
|
|
270
|
+
try {
|
|
271
|
+
descriptor = openSync(ownerFile, constants.O_CREAT | constants.O_WRONLY | constants.O_TRUNC, 0o600);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Best effort: without the owner pid the lock is still age-reclaimable, so a
|
|
275
|
+
// failure to record it degrades to the age bound rather than orphaning.
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Keep the descriptor so it can be closed in `finally` — never leak an fd per
|
|
279
|
+
// reclaim (R3-F5).
|
|
280
|
+
try {
|
|
281
|
+
writeSync(descriptor, `${process.pid}\n`);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Recording the pid is best-effort; the age bound still bounds reclaim.
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
closeSync(descriptor);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Atomically create the fence file and write its content, failing (returning
|
|
292
|
+
* `false`) iff the file already exists. The `O_EXCL` create is the single point
|
|
293
|
+
* that decides a concurrent race; the owner bytes are written into the same
|
|
294
|
+
* descriptor and fsynced before it is closed.
|
|
295
|
+
*/
|
|
296
|
+
function tryCreateFenceExclusive(path, content) {
|
|
297
|
+
let descriptor;
|
|
298
|
+
try {
|
|
299
|
+
descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (isEexist(error))
|
|
303
|
+
return false;
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
writeSync(descriptor, content);
|
|
308
|
+
fsyncSync(descriptor);
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
closeSync(descriptor);
|
|
312
|
+
}
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
/** Build the idempotent release handle that only clears a fence this owner holds. */
|
|
316
|
+
function makeFenceRelease(home, ownerPid) {
|
|
317
|
+
return () => {
|
|
318
|
+
const current = readUpgradeFence(home);
|
|
319
|
+
if (current !== null && current.ownerPid === ownerPid)
|
|
320
|
+
clearUpgradeFence(home);
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Unconditionally remove any fence marker (used by release and stale reclaim). */
|
|
324
|
+
export function clearUpgradeFence(home) {
|
|
325
|
+
rmSync(fencePath(home), { force: true });
|
|
326
|
+
}
|
|
327
|
+
function processIsAlive(pid) {
|
|
328
|
+
try {
|
|
329
|
+
process.kill(pid, 0);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function isEnoent(error) {
|
|
337
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
338
|
+
}
|
|
339
|
+
function isEexist(error) {
|
|
340
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* fsync the directory holding the fence so a crash right after acquisition still
|
|
344
|
+
* observes the fence on the next boot (the marker's whole purpose is durability
|
|
345
|
+
* across an interrupted upgrade). Best-effort: a platform that cannot open a
|
|
346
|
+
* directory for fsync must not fail the acquisition.
|
|
347
|
+
*/
|
|
348
|
+
function fsyncDirectory(directory) {
|
|
349
|
+
let descriptor;
|
|
350
|
+
try {
|
|
351
|
+
descriptor = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
fsyncSync(descriptor);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Some filesystems reject fsync on a directory fd; the O_EXCL create already
|
|
361
|
+
// committed the inode, so this is not fatal to correctness.
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
closeSync(descriptor);
|
|
365
|
+
}
|
|
366
|
+
}
|
package/dist/task/task.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export function createTask(id, title, now, metadata = {}) {
|
|
2
2
|
const timestamp = now.toISOString();
|
|
3
3
|
return {
|
|
4
|
-
schemaVersion:
|
|
4
|
+
schemaVersion: 3,
|
|
5
5
|
id: requireSafeIdentity(id, "Task id"),
|
|
6
6
|
title: requireText(title, "Task title"),
|
|
7
7
|
...cloneMetadata(metadata),
|
|
@@ -15,10 +15,50 @@ export function activateTask(task, now) {
|
|
|
15
15
|
throw new Error(`Cannot activate archived Task: ${task.id}.`);
|
|
16
16
|
if (task.status === "completed")
|
|
17
17
|
throw new Error(`Cannot activate completed Task ${task.id}; reopen it instead.`);
|
|
18
|
+
if (task.status === "retired")
|
|
19
|
+
throw new Error(`Cannot activate retired Task: ${task.id}.`);
|
|
18
20
|
if (task.status === "active")
|
|
19
21
|
return task;
|
|
20
22
|
return { ...task, status: "active", updatedAt: now.toISOString() };
|
|
21
23
|
}
|
|
24
|
+
/** Explicitly retires a stale aggregate while retaining all historical facts. */
|
|
25
|
+
export function retireTask(task, input, now) {
|
|
26
|
+
validateTask(task);
|
|
27
|
+
const summary = requireText(input.summary, "Task retirement summary");
|
|
28
|
+
const by = input.by;
|
|
29
|
+
if (!["user", "operator", "leader"].includes(by)) {
|
|
30
|
+
throw new Error(`Task retirement actor is invalid: ${String(by)}.`);
|
|
31
|
+
}
|
|
32
|
+
if (input.replacementTaskId !== undefined) {
|
|
33
|
+
const replacementTaskId = requireSafeIdentity(input.replacementTaskId, "Replacement Task id");
|
|
34
|
+
if (replacementTaskId === task.id) {
|
|
35
|
+
throw new Error("A Task cannot replace itself.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (task.status === "retired") {
|
|
39
|
+
if (task.retiredBy === by
|
|
40
|
+
&& task.retirementSummary === summary
|
|
41
|
+
&& task.replacementTaskId === input.replacementTaskId) {
|
|
42
|
+
return task;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Task already has an explicit retirement: ${task.id}.`);
|
|
45
|
+
}
|
|
46
|
+
if (task.status === "archived" || task.status === "completed") {
|
|
47
|
+
throw new Error(`Task cannot be retired from ${task.status}: ${task.id}.`);
|
|
48
|
+
}
|
|
49
|
+
const timestamp = now.toISOString();
|
|
50
|
+
return validateTask({
|
|
51
|
+
...task,
|
|
52
|
+
status: "retired",
|
|
53
|
+
retiredAt: timestamp,
|
|
54
|
+
retiredBy: by,
|
|
55
|
+
retirementSummary: summary,
|
|
56
|
+
...(input.replacementTaskId === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: { replacementTaskId: input.replacementTaskId }),
|
|
59
|
+
updatedAt: timestamp
|
|
60
|
+
});
|
|
61
|
+
}
|
|
22
62
|
export function completeTask(task, now, completion) {
|
|
23
63
|
if (task.status === "completed")
|
|
24
64
|
return task;
|
|
@@ -47,8 +87,12 @@ export function reopenTask(task, now) {
|
|
|
47
87
|
export function archiveTask(task, now, archive) {
|
|
48
88
|
if (task.status === "archived")
|
|
49
89
|
return task;
|
|
90
|
+
if (task.status !== "completed"
|
|
91
|
+
&& task.status !== "retired") {
|
|
92
|
+
throw new Error(`Only a completed or retired Task can be archived: ${task.id}.`);
|
|
93
|
+
}
|
|
50
94
|
const timestamp = now.toISOString();
|
|
51
|
-
return {
|
|
95
|
+
return validateTask({
|
|
52
96
|
...task,
|
|
53
97
|
status: "archived",
|
|
54
98
|
archivedAt: timestamp,
|
|
@@ -56,7 +100,7 @@ export function archiveTask(task, now, archive) {
|
|
|
56
100
|
...(archive?.reason === undefined ? {} : { archiveReason: archive.reason.trim() }),
|
|
57
101
|
...(archive?.summary === undefined ? {} : { archiveSummary: archive.summary.trim() }),
|
|
58
102
|
updatedAt: timestamp
|
|
59
|
-
};
|
|
103
|
+
});
|
|
60
104
|
}
|
|
61
105
|
export function updateTaskArchived(task, archived, now, archive) {
|
|
62
106
|
if (archived)
|
|
@@ -76,14 +120,13 @@ export function updateTaskMetadata(task, metadata, now) {
|
|
|
76
120
|
? metadata.tags
|
|
77
121
|
: [...metadata.tags]);
|
|
78
122
|
applyOptional(updated, "dueAt", metadata.dueAt);
|
|
79
|
-
if (metadata.
|
|
80
|
-
updated.
|
|
123
|
+
if (metadata.projectBindings !== undefined) {
|
|
124
|
+
updated.projectBindings = normalizeProjectBindings(metadata.projectBindings);
|
|
81
125
|
}
|
|
82
|
-
if (metadata.baseRef !== undefined)
|
|
83
|
-
updated.baseRef = requireText(metadata.baseRef, "Task base ref");
|
|
84
126
|
if (metadata.cwd !== undefined)
|
|
85
127
|
updated.cwd = requireText(metadata.cwd, "Task workspace");
|
|
86
|
-
|
|
128
|
+
if (metadata.requireIntegration === true)
|
|
129
|
+
updated.requireIntegration = true;
|
|
87
130
|
return updated;
|
|
88
131
|
}
|
|
89
132
|
function applyOptional(task, key, value) {
|
|
@@ -101,11 +144,11 @@ export function isTaskArchived(task) {
|
|
|
101
144
|
return task.status === "archived";
|
|
102
145
|
}
|
|
103
146
|
export function validateTask(task) {
|
|
104
|
-
if (task.schemaVersion !==
|
|
105
|
-
throw new Error("Task must use schemaVersion
|
|
147
|
+
if (task.schemaVersion !== 3)
|
|
148
|
+
throw new Error("Task must use schemaVersion 3.");
|
|
106
149
|
requireSafeIdentity(task.id, "Task id");
|
|
107
150
|
requireText(task.title, "Task title");
|
|
108
|
-
if (!["draft", "active", "completed", "archived"].includes(task.status)) {
|
|
151
|
+
if (!["draft", "active", "completed", "retired", "archived"].includes(task.status)) {
|
|
109
152
|
throw new Error(`Task status is invalid: ${String(task.status)}.`);
|
|
110
153
|
}
|
|
111
154
|
requireTimestamp(task.createdAt, "Task createdAt");
|
|
@@ -127,13 +170,12 @@ export function validateTask(task) {
|
|
|
127
170
|
}
|
|
128
171
|
if (task.dueAt !== undefined)
|
|
129
172
|
requireTimestamp(task.dueAt, "Task dueAt");
|
|
130
|
-
|
|
131
|
-
requireSafeIdentity(task.repositoryId, "Repository id");
|
|
132
|
-
if (task.baseRef !== undefined)
|
|
133
|
-
requireText(task.baseRef, "Task base ref");
|
|
173
|
+
normalizeProjectBindings(task.projectBindings);
|
|
134
174
|
if (task.cwd !== undefined)
|
|
135
175
|
requireText(task.cwd, "Task workspace");
|
|
136
|
-
|
|
176
|
+
if (task.requireIntegration !== undefined && task.requireIntegration !== true) {
|
|
177
|
+
throw new Error("Task requireIntegration must be true when present.");
|
|
178
|
+
}
|
|
137
179
|
const completionFields = [task.completedAt, task.completedBy, task.completionSummary];
|
|
138
180
|
const hasAnyCompletion = completionFields.some((value) => value !== undefined);
|
|
139
181
|
const hasAllCompletion = completionFields.every((value) => value !== undefined);
|
|
@@ -150,9 +192,39 @@ export function validateTask(task) {
|
|
|
150
192
|
if (task.status === "completed" && !hasAllCompletion) {
|
|
151
193
|
throw new Error("A completed Task requires completedAt, completedBy, and completionSummary.");
|
|
152
194
|
}
|
|
153
|
-
if (
|
|
195
|
+
if (["draft", "active", "retired"].includes(task.status)
|
|
196
|
+
&& hasAnyCompletion) {
|
|
154
197
|
throw new Error(`Task completion metadata is invalid for ${task.status} status.`);
|
|
155
198
|
}
|
|
199
|
+
const retirementFields = [
|
|
200
|
+
task.retiredAt,
|
|
201
|
+
task.retiredBy,
|
|
202
|
+
task.retirementSummary,
|
|
203
|
+
task.replacementTaskId
|
|
204
|
+
];
|
|
205
|
+
const hasAnyRetirement = retirementFields.some((value) => value !== undefined);
|
|
206
|
+
const retired = task.status === "retired";
|
|
207
|
+
const archivedRetirement = task.status === "archived" && hasAnyRetirement;
|
|
208
|
+
if (retired || archivedRetirement) {
|
|
209
|
+
if (task.retiredAt === undefined
|
|
210
|
+
|| task.retiredBy === undefined
|
|
211
|
+
|| task.retirementSummary === undefined) {
|
|
212
|
+
throw new Error("A retired Task requires retiredAt, retiredBy, and retirementSummary.");
|
|
213
|
+
}
|
|
214
|
+
requireTimestamp(task.retiredAt, "Task retiredAt");
|
|
215
|
+
if (!["user", "operator", "leader"].includes(task.retiredBy)) {
|
|
216
|
+
throw new Error(`Task retiredBy is invalid: ${String(task.retiredBy)}.`);
|
|
217
|
+
}
|
|
218
|
+
requireText(task.retirementSummary, "Task retirement summary");
|
|
219
|
+
if (task.replacementTaskId !== undefined) {
|
|
220
|
+
const replacementTaskId = requireSafeIdentity(task.replacementTaskId, "Replacement Task id");
|
|
221
|
+
if (replacementTaskId === task.id)
|
|
222
|
+
throw new Error("A Task cannot replace itself.");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (hasAnyRetirement) {
|
|
226
|
+
throw new Error(`Task retirement metadata is invalid for ${task.status} status.`);
|
|
227
|
+
}
|
|
156
228
|
const hasAnyArchive = [task.archivedAt, task.archivedBy, task.archiveReason, task.archiveSummary]
|
|
157
229
|
.some((value) => value !== undefined);
|
|
158
230
|
if (task.status === "archived") {
|
|
@@ -167,6 +239,9 @@ export function validateTask(task) {
|
|
|
167
239
|
requireText(task.archiveReason, "Task archive reason");
|
|
168
240
|
if (task.archiveSummary !== undefined)
|
|
169
241
|
requireText(task.archiveSummary, "Task archive summary");
|
|
242
|
+
if (hasAllCompletion === hasAnyRetirement) {
|
|
243
|
+
throw new Error("An archived Task must preserve exactly one completion or retirement outcome.");
|
|
244
|
+
}
|
|
170
245
|
}
|
|
171
246
|
else if (hasAnyArchive) {
|
|
172
247
|
throw new Error(`Task archive metadata is invalid for ${task.status} status.`);
|
|
@@ -179,19 +254,50 @@ function cloneMetadata(metadata) {
|
|
|
179
254
|
...(metadata.priority === undefined ? {} : { priority: metadata.priority }),
|
|
180
255
|
...(metadata.tags === undefined ? {} : { tags: [...metadata.tags] }),
|
|
181
256
|
...(metadata.dueAt === undefined ? {} : { dueAt: metadata.dueAt }),
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
...(metadata.baseRef === undefined ? {} : { baseRef: requireText(metadata.baseRef, "Task base ref") }),
|
|
186
|
-
...(metadata.cwd === undefined ? {} : { cwd: requireText(metadata.cwd, "Task workspace") })
|
|
257
|
+
projectBindings: normalizeProjectBindings(metadata.projectBindings ?? []),
|
|
258
|
+
...(metadata.cwd === undefined ? {} : { cwd: requireText(metadata.cwd, "Task workspace") }),
|
|
259
|
+
...(metadata.requireIntegration === true ? { requireIntegration: true } : {})
|
|
187
260
|
};
|
|
188
|
-
validateRepositorySelection(cloned);
|
|
189
261
|
return cloned;
|
|
190
262
|
}
|
|
191
|
-
function
|
|
192
|
-
if (
|
|
193
|
-
throw new Error("Task
|
|
263
|
+
function normalizeProjectBindings(bindings) {
|
|
264
|
+
if (!Array.isArray(bindings))
|
|
265
|
+
throw new Error("Task Project bindings are invalid.");
|
|
266
|
+
const projectIds = new Set();
|
|
267
|
+
const directories = new Set();
|
|
268
|
+
return bindings.map((binding) => {
|
|
269
|
+
const projectId = requireSafeIdentity(binding.projectId, "Project id");
|
|
270
|
+
const directory = requireSafeIdentity(binding.directory, "Project directory");
|
|
271
|
+
const baseRef = requireText(binding.baseRef, "Task base ref");
|
|
272
|
+
if (projectIds.has(projectId)) {
|
|
273
|
+
throw new Error(`Task Project is duplicated: ${projectId}.`);
|
|
274
|
+
}
|
|
275
|
+
if (directories.has(directory)) {
|
|
276
|
+
throw new Error(`Task Project directory is duplicated: ${directory}.`);
|
|
277
|
+
}
|
|
278
|
+
projectIds.add(projectId);
|
|
279
|
+
directories.add(directory);
|
|
280
|
+
return { projectId, directory, baseRef };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
export function taskProjectBinding(task, projectId) {
|
|
284
|
+
return task.projectBindings.find((binding) => binding.projectId === projectId);
|
|
285
|
+
}
|
|
286
|
+
export function taskHasProjects(task) {
|
|
287
|
+
return task.projectBindings.length > 0;
|
|
288
|
+
}
|
|
289
|
+
export function taskProjectIds(task) {
|
|
290
|
+
return task.projectBindings.map(({ projectId }) => projectId);
|
|
291
|
+
}
|
|
292
|
+
export function addTaskProjectBinding(task, binding, now) {
|
|
293
|
+
if (taskProjectBinding(task, binding.projectId) !== undefined) {
|
|
294
|
+
throw new Error(`Task already contains Project: ${binding.projectId}.`);
|
|
194
295
|
}
|
|
296
|
+
return validateTask({
|
|
297
|
+
...task,
|
|
298
|
+
projectBindings: normalizeProjectBindings([...task.projectBindings, binding]),
|
|
299
|
+
updatedAt: now.toISOString()
|
|
300
|
+
});
|
|
195
301
|
}
|
|
196
302
|
function requireSafeIdentity(value, label) {
|
|
197
303
|
const normalized = requireText(value, label);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { requireIdentity } from "../domain/validation.js";
|
|
2
|
+
export const TASK_RECORD_ID_PREFIXES = {
|
|
3
|
+
workItem: "work-item",
|
|
4
|
+
agentRun: "agent-run",
|
|
5
|
+
reviewRound: "review-round",
|
|
6
|
+
changeSet: "change-set",
|
|
7
|
+
integrationAttempt: "integration",
|
|
8
|
+
message: "message",
|
|
9
|
+
inputRequest: "input",
|
|
10
|
+
decision: "decision",
|
|
11
|
+
milestone: "milestone",
|
|
12
|
+
event: "event"
|
|
13
|
+
};
|
|
14
|
+
export function formatTaskRecordReference(taskId, localId, kind) {
|
|
15
|
+
const reference = validateTaskRecordReference({ taskId, localId }, kind);
|
|
16
|
+
return `${reference.taskId}/${reference.localId}`;
|
|
17
|
+
}
|
|
18
|
+
export function formatAgentRunReceiptId(taskId, runId) {
|
|
19
|
+
return `agent-run:${formatTaskRecordReference(taskId, runId, "agentRun")}`;
|
|
20
|
+
}
|
|
21
|
+
export function formatInputRequestReceiptId(taskId, requestId) {
|
|
22
|
+
return `input-request:${formatTaskRecordReference(taskId, requestId, "inputRequest")}`;
|
|
23
|
+
}
|
|
24
|
+
export function resolveTaskRecordReference(value, options) {
|
|
25
|
+
const reference = normalizedReference(value, options.label);
|
|
26
|
+
const firstSeparator = reference.indexOf("/");
|
|
27
|
+
if (firstSeparator < 0) {
|
|
28
|
+
if (options.contextTaskId === undefined) {
|
|
29
|
+
throw new Error(`${options.label} has no Task context: ${reference}; `
|
|
30
|
+
+ `use task-<n>/${reference}.`);
|
|
31
|
+
}
|
|
32
|
+
return validateTaskRecordReference({
|
|
33
|
+
taskId: options.contextTaskId,
|
|
34
|
+
localId: reference
|
|
35
|
+
}, options.kind);
|
|
36
|
+
}
|
|
37
|
+
if (firstSeparator === 0
|
|
38
|
+
|| firstSeparator === reference.length - 1
|
|
39
|
+
|| reference.indexOf("/", firstSeparator + 1) >= 0) {
|
|
40
|
+
throw new Error(`${options.label} must use taskId/localId.`);
|
|
41
|
+
}
|
|
42
|
+
return validateTaskRecordReference({
|
|
43
|
+
taskId: reference.slice(0, firstSeparator),
|
|
44
|
+
localId: reference.slice(firstSeparator + 1)
|
|
45
|
+
}, options.kind);
|
|
46
|
+
}
|
|
47
|
+
export function validateTaskRecordReference(value, kind) {
|
|
48
|
+
const taskId = requireIdentity(value.taskId, "Task id");
|
|
49
|
+
const localId = requireIdentity(value.localId, `${TASK_RECORD_ID_PREFIXES[kind]} local id`);
|
|
50
|
+
const match = new RegExp(`^${TASK_RECORD_ID_PREFIXES[kind]}-([1-9]\\d*)$`).exec(localId);
|
|
51
|
+
if (match === null || !Number.isSafeInteger(Number(match[1]))) {
|
|
52
|
+
throw new Error(`${TASK_RECORD_ID_PREFIXES[kind]} local id is invalid: ${localId}.`);
|
|
53
|
+
}
|
|
54
|
+
return { taskId, localId };
|
|
55
|
+
}
|
|
56
|
+
function normalizedReference(value, label) {
|
|
57
|
+
if (typeof value !== "string" || value.includes("\0")) {
|
|
58
|
+
throw new Error(`${label} is invalid.`);
|
|
59
|
+
}
|
|
60
|
+
const normalized = value.trim();
|
|
61
|
+
if (normalized.length === 0)
|
|
62
|
+
throw new Error(`${label} is required.`);
|
|
63
|
+
if (normalized !== value)
|
|
64
|
+
throw new Error(`${label} must be normalized.`);
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|