@zq-silk/yui 0.15.8 → 0.15.11
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 +2 -0
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +211 -14
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/cli/commandCatalog.js +40 -16
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +403 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/operatorCommands.js +33 -2
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActivationCommands.js +22 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +846 -155
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/runContextPack.js +28 -16
- package/dist/context/taskContext.js +64 -5
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +11 -2
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/kernel/builtinCapabilities.js +32 -24
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +423 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/plugins/pluginService.js +11 -3
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/projectResource.js +0 -48
- package/dist/resources/projectResourceService.js +3 -81
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +29 -16
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +173 -7
- package/dist/storage/sqliteStore.js +41 -22
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/taskActivation.js +26 -0
- package/dist/task/taskActivationService.js +85 -69
- package/dist/task/taskSubmission.js +236 -0
- package/dist/web/assets/client/app.js +58 -2
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +202 -7
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +135 -4
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +225 -8
- package/dist/workItem/workItem.js +14 -10
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/agent-result-consumption.md +2 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +2 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +44 -32
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -79
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +2 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +2 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +320 -217
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +2 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +41 -0
- package/docs/release-workflow.zh-CN.md +266 -0
- package/docs/roles-and-configuration.md +2 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +225 -1
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -57
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +2 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +2 -0
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +26 -0
- package/docs/testing/verification-levels.zh-CN.md +80 -0
- package/i18n/README.zh-CN.md +199 -10
- package/package.json +2 -1
- package/skills/yui-leader/SKILL.md +88 -331
- package/skills/yui-leader/references/execution.md +405 -0
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/task-plugins.md +8 -4
- package/skills/yui-operator/SKILL.md +22 -4
- package/skills/yui-runtime/SKILL.md +27 -0
- package/skills/yui-runtime/references/publication.md +20 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Short per-Task commit fence for artifact repositories.
|
|
5
|
+
*
|
|
6
|
+
* A single meaningful artifact update is: write file(s) → verify scope →
|
|
7
|
+
* local commit. Two concurrent saves in the same Task repo must not interleave
|
|
8
|
+
* their `git add`/`commit` steps, or one could sweep the other's staged paths
|
|
9
|
+
* into the wrong commit, or race the expected-HEAD check. This fence serializes
|
|
10
|
+
* the commit critical section for one Task.
|
|
11
|
+
*
|
|
12
|
+
* Unlike the Project maintenance fence (which guards long operations and fails
|
|
13
|
+
* fast so the CLI is not blocked), an artifact save is a short operation, so a
|
|
14
|
+
* contended fence briefly RETRIES before giving up. It is otherwise the same
|
|
15
|
+
* crash-safe design: an exclusive directory under the Home's `locks/` area
|
|
16
|
+
* (O_EXCL via mkdir), an owner file recording PID + process start time so a
|
|
17
|
+
* recycled PID can never pass for the original holder, and reclaim of a stale
|
|
18
|
+
* (dead-owner) lock.
|
|
19
|
+
*/
|
|
20
|
+
const ARTIFACT_COMMIT_LOCK_TIMEOUT_MS = 5_000;
|
|
21
|
+
const ARTIFACT_COMMIT_LOCK_RETRY_MS = 15;
|
|
22
|
+
/** A lock older than this with a dead owner is reclaimed. */
|
|
23
|
+
const STALE_ARTIFACT_COMMIT_LOCK_AGE_MS = 2_000;
|
|
24
|
+
/**
|
|
25
|
+
* Raised when a Task's artifact commit fence could not be acquired within the
|
|
26
|
+
* retry window. The caller made no changes and is safe to retry.
|
|
27
|
+
*/
|
|
28
|
+
export class ArtifactCommitLockedError extends Error {
|
|
29
|
+
taskId;
|
|
30
|
+
retryable = true;
|
|
31
|
+
constructor(taskId) {
|
|
32
|
+
super(`Another artifact save is in progress for ${taskId}; retry once it finishes.`);
|
|
33
|
+
this.name = "ArtifactCommitLockedError";
|
|
34
|
+
this.taskId = taskId;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Directory of one Task's artifact commit fence, below the Home's locks area. */
|
|
38
|
+
export function artifactCommitLockPath(home, taskId) {
|
|
39
|
+
return join(home, "locks", "task-artifacts", `${taskId}.lock`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Acquire one Task's artifact commit fence. Returns the release function;
|
|
43
|
+
* callers MUST release on every exit path (try/finally). A live holder is
|
|
44
|
+
* retried until the timeout, then fails with {@link ArtifactCommitLockedError};
|
|
45
|
+
* a stale (dead) holder is reclaimed. There is no in-process reentrancy: every
|
|
46
|
+
* acquisition contends.
|
|
47
|
+
*/
|
|
48
|
+
export function acquireArtifactCommitLock(home, taskId) {
|
|
49
|
+
const lock = artifactCommitLockPath(home, taskId);
|
|
50
|
+
mkdirSync(join(home, "locks", "task-artifacts"), { recursive: true, mode: 0o700 });
|
|
51
|
+
const deadline = Date.now() + ARTIFACT_COMMIT_LOCK_TIMEOUT_MS;
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
55
|
+
const ownerIdentity = writeOwnerIdentity(lock);
|
|
56
|
+
let released = false;
|
|
57
|
+
return () => {
|
|
58
|
+
if (released)
|
|
59
|
+
return;
|
|
60
|
+
released = true;
|
|
61
|
+
releaseOwnedArtifactCommitLock(lock, ownerIdentity);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!isEexist(error))
|
|
66
|
+
throw error;
|
|
67
|
+
reclaimStaleArtifactCommitLock(lock);
|
|
68
|
+
if (Date.now() >= deadline)
|
|
69
|
+
throw new ArtifactCommitLockedError(taskId);
|
|
70
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ARTIFACT_COMMIT_LOCK_RETRY_MS);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Write this process's owner identity and return the exact bytes recorded. */
|
|
75
|
+
function writeOwnerIdentity(lock) {
|
|
76
|
+
const identity = `${process.pid}:${processStartIdentity() ?? ""}`;
|
|
77
|
+
writeFileSync(join(lock, "owner"), `${identity}\n`, { mode: 0o600 });
|
|
78
|
+
return identity;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Remove the lock directory only when its owner file still records the exact
|
|
82
|
+
* identity this handle acquired. A replaced (successor) lock is left intact.
|
|
83
|
+
*/
|
|
84
|
+
function releaseOwnedArtifactCommitLock(lock, ownerIdentity) {
|
|
85
|
+
let owner;
|
|
86
|
+
try {
|
|
87
|
+
owner = readFileSync(join(lock, "owner"), "utf8").trim();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (isEnoent(error))
|
|
91
|
+
return; // already gone; nothing to release.
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (owner !== ownerIdentity)
|
|
95
|
+
return;
|
|
96
|
+
rmSync(lock, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Reclaim a stale (dead-owner) lock through a crash-safe compare-and-delete
|
|
100
|
+
* critical section, serialized by a reclaim lock so two contenders cannot both
|
|
101
|
+
* delete a successor. Under the reclaim lock we re-read the owner and delete
|
|
102
|
+
* only when it is still the exact stale instance observed above.
|
|
103
|
+
*/
|
|
104
|
+
function reclaimStaleArtifactCommitLock(lock) {
|
|
105
|
+
let expectedOwner;
|
|
106
|
+
try {
|
|
107
|
+
expectedOwner = readFileSync(join(lock, "owner"), "utf8");
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (!isEnoent(error))
|
|
111
|
+
throw error;
|
|
112
|
+
// Ownerless lock: a crash between mkdir and owner publication. Reclaim only
|
|
113
|
+
// when old enough that the creator is not still initializing.
|
|
114
|
+
try {
|
|
115
|
+
if (Date.now() - statSync(lock).mtimeMs < STALE_ARTIFACT_COMMIT_LOCK_AGE_MS)
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
catch (statError) {
|
|
119
|
+
if (isEnoent(statError))
|
|
120
|
+
return;
|
|
121
|
+
throw statError;
|
|
122
|
+
}
|
|
123
|
+
expectedOwner = null;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
if (Date.now() - statSync(lock).mtimeMs < STALE_ARTIFACT_COMMIT_LOCK_AGE_MS)
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (isEnoent(error))
|
|
131
|
+
return;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (expectedOwner !== null && lockOwnerIsAlive(lock))
|
|
135
|
+
return;
|
|
136
|
+
const reclaimLock = `${lock}.reclaim`;
|
|
137
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(reclaimLock, { mode: 0o700 });
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (!isEexist(error))
|
|
143
|
+
throw error;
|
|
144
|
+
if (reclaimOrphanedReclaimLock(reclaimLock))
|
|
145
|
+
continue;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
writeOwnerIdentity(reclaimLock);
|
|
150
|
+
let stat;
|
|
151
|
+
try {
|
|
152
|
+
stat = statSync(lock);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (isEnoent(error))
|
|
156
|
+
return;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() - stat.mtimeMs < STALE_ARTIFACT_COMMIT_LOCK_AGE_MS)
|
|
160
|
+
return;
|
|
161
|
+
let currentOwner;
|
|
162
|
+
try {
|
|
163
|
+
currentOwner = readFileSync(join(lock, "owner"), "utf8");
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (!isEnoent(error))
|
|
167
|
+
throw error;
|
|
168
|
+
if (expectedOwner !== null)
|
|
169
|
+
return;
|
|
170
|
+
currentOwner = null;
|
|
171
|
+
}
|
|
172
|
+
if (currentOwner !== expectedOwner)
|
|
173
|
+
return;
|
|
174
|
+
if (currentOwner !== null && lockOwnerIsAlive(lock))
|
|
175
|
+
return;
|
|
176
|
+
rmSync(lock, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reclaim a reclaim-lock directory only when provably orphaned: dead (or
|
|
186
|
+
* unrecorded) owner AND older than the age bound. Returns true when it removed
|
|
187
|
+
* the lock (caller retries), false otherwise.
|
|
188
|
+
*/
|
|
189
|
+
function reclaimOrphanedReclaimLock(reclaimLock) {
|
|
190
|
+
try {
|
|
191
|
+
if (Date.now() - statSync(reclaimLock).mtimeMs < STALE_ARTIFACT_COMMIT_LOCK_AGE_MS)
|
|
192
|
+
return false;
|
|
193
|
+
if (lockOwnerIsAlive(reclaimLock))
|
|
194
|
+
return false;
|
|
195
|
+
rmSync(reclaimLock, { recursive: true, force: true });
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
return isEnoent(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function lockOwnerIsAlive(lock) {
|
|
203
|
+
let owner;
|
|
204
|
+
try {
|
|
205
|
+
owner = readFileSync(join(lock, "owner"), "utf8").trim();
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (isEnoent(error))
|
|
209
|
+
return false;
|
|
210
|
+
return true; // Unreadable owner fails closed: treat the fence as held.
|
|
211
|
+
}
|
|
212
|
+
const separator = owner.indexOf(":");
|
|
213
|
+
const pid = Number.parseInt(separator < 0 ? owner : owner.slice(0, separator), 10);
|
|
214
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
215
|
+
return false;
|
|
216
|
+
const recordedIdentity = separator < 0 ? "" : owner.slice(separator + 1);
|
|
217
|
+
const currentIdentity = processStartIdentity(pid);
|
|
218
|
+
if (currentIdentity !== undefined) {
|
|
219
|
+
return recordedIdentity !== "" && currentIdentity === recordedIdentity;
|
|
220
|
+
}
|
|
221
|
+
return processIsAlive(pid);
|
|
222
|
+
}
|
|
223
|
+
/** Linux process start time (clock ticks since boot) for a PID; undefined off /proc. */
|
|
224
|
+
function processStartIdentity(pid = process.pid) {
|
|
225
|
+
try {
|
|
226
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
227
|
+
const close = stat.lastIndexOf(")");
|
|
228
|
+
const fields = stat.slice(close + 2).split(" ");
|
|
229
|
+
return fields[19] ?? "0"; // field 22: starttime
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function processIsAlive(pid) {
|
|
236
|
+
try {
|
|
237
|
+
process.kill(pid, 0);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function isEnoent(error) {
|
|
245
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
246
|
+
}
|
|
247
|
+
function isEexist(error) {
|
|
248
|
+
return error instanceof Error && "code" in error && error.code === "EEXIST";
|
|
249
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Filesystem layout and path-safety for per-Task artifact repositories.
|
|
5
|
+
*
|
|
6
|
+
* At most one local Git repository per Task lives at
|
|
7
|
+
* `<YUI_HOME>/task-artifacts/<task-id>/`. This location is derived DIRECTLY
|
|
8
|
+
* from the Home (not through `resolveWorkspaceRoot`, which forbids paths inside
|
|
9
|
+
* YUI_HOME): artifact repos are deliberately Home-internal working stores, not
|
|
10
|
+
* delivery workspaces.
|
|
11
|
+
*
|
|
12
|
+
* `relativePath` is an artifact's identity. Every path a caller supplies is
|
|
13
|
+
* validated against traversal (`..`), absolute escape, and symbolic-link
|
|
14
|
+
* escape before it is used for a filesystem or Git operation. The canonical
|
|
15
|
+
* form is POSIX (forward-slash) so it doubles as a stable Git pathspec and a
|
|
16
|
+
* stable identity across platforms.
|
|
17
|
+
*/
|
|
18
|
+
const RESERVED_SEGMENTS = Object.freeze([
|
|
19
|
+
".", "..", "__proto__", "prototype", "constructor"
|
|
20
|
+
]);
|
|
21
|
+
/** Root under the Home that holds every Task's artifact repository. */
|
|
22
|
+
export function taskArtifactsRoot(home) {
|
|
23
|
+
return join(resolve(home), "task-artifacts");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Absolute path of one Task's artifact repository. The Task id is validated as
|
|
27
|
+
* a single safe path segment, so a crafted id cannot escape the artifacts root.
|
|
28
|
+
*/
|
|
29
|
+
export function taskArtifactRepoPath(home, taskId) {
|
|
30
|
+
return join(taskArtifactsRoot(home), safePathSegment(taskId, "Task id"));
|
|
31
|
+
}
|
|
32
|
+
/** Validate one path segment (Task id / directory name). Mirrors safePathSegment in the workspace layer. */
|
|
33
|
+
export function safePathSegment(value, label = "Identity") {
|
|
34
|
+
const normalized = value.trim();
|
|
35
|
+
if (normalized.length === 0 ||
|
|
36
|
+
RESERVED_SEGMENTS.includes(normalized) ||
|
|
37
|
+
/[\/\\\0]/.test(normalized)) {
|
|
38
|
+
throw new Error(`${label} is invalid for managed artifact layout.`);
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate and canonicalize an artifact `relativePath` to POSIX form. Rejects
|
|
44
|
+
* absolute paths, empty input, backslashes, control characters, and any `.`,
|
|
45
|
+
* `..`, empty, or reserved segment. The result is a forward-slash relative path
|
|
46
|
+
* suitable as both a Git pathspec and a stable identity.
|
|
47
|
+
*
|
|
48
|
+
* This is a PURE lexical check; it does not touch the filesystem. Use
|
|
49
|
+
* {@link resolveContainedArtifactPath} to additionally guard against symbolic
|
|
50
|
+
* links before a filesystem read or write.
|
|
51
|
+
*/
|
|
52
|
+
export function safeRelativeArtifactPath(relativePath) {
|
|
53
|
+
if (typeof relativePath !== "string" || relativePath.length === 0) {
|
|
54
|
+
throw new Error("Artifact relativePath is required.");
|
|
55
|
+
}
|
|
56
|
+
if (relativePath.includes("\0")) {
|
|
57
|
+
throw new Error("Artifact relativePath contains a NUL byte.");
|
|
58
|
+
}
|
|
59
|
+
if (relativePath.includes("\\")) {
|
|
60
|
+
throw new Error("Artifact relativePath must use forward slashes.");
|
|
61
|
+
}
|
|
62
|
+
if (isAbsolute(relativePath) || relativePath.startsWith("/")) {
|
|
63
|
+
throw new Error("Artifact relativePath must be relative.");
|
|
64
|
+
}
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
if (/[\x00-\x1f]/.test(relativePath)) {
|
|
67
|
+
throw new Error("Artifact relativePath contains control characters.");
|
|
68
|
+
}
|
|
69
|
+
// An artifact's identity is a literal path, never a pattern. Reject Git glob
|
|
70
|
+
// and pathspec-magic metacharacters (`*` `?` `[` `]` `:`) so a relativePath can
|
|
71
|
+
// never be interpreted as a wildcard that matches OTHER paths, and never carry
|
|
72
|
+
// pathspec magic like `:(exclude)`. (The managed runner also forces literal
|
|
73
|
+
// pathspecs, but rejecting them in the identity keeps identities unambiguous.)
|
|
74
|
+
if (/[*?[\]:]/.test(relativePath)) {
|
|
75
|
+
throw new Error("Artifact relativePath contains a glob or pathspec metacharacter (*?[]:).");
|
|
76
|
+
}
|
|
77
|
+
const segments = relativePath.split("/");
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
if (segment.length === 0) {
|
|
80
|
+
throw new Error("Artifact relativePath has an empty path segment.");
|
|
81
|
+
}
|
|
82
|
+
if (RESERVED_SEGMENTS.includes(segment)) {
|
|
83
|
+
throw new Error(`Artifact relativePath has a forbidden segment: ${segment}.`);
|
|
84
|
+
}
|
|
85
|
+
// The Git control directory is never a valid artifact location. Reject it
|
|
86
|
+
// case-insensitively so a case-insensitive filesystem cannot alias into it.
|
|
87
|
+
if (segment.toLowerCase() === ".git") {
|
|
88
|
+
throw new Error("Artifact relativePath may not enter the Git control directory.");
|
|
89
|
+
}
|
|
90
|
+
if (segment.trim() !== segment) {
|
|
91
|
+
throw new Error("Artifact relativePath segment has leading or trailing whitespace.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return segments.join("/");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a validated `relativePath` to an absolute filesystem path inside the
|
|
98
|
+
* repository, and prove it does not escape through a symbolic link. Every
|
|
99
|
+
* existing ancestor component (and the target itself, when present) is
|
|
100
|
+
* `lstat`ed; a symlink anywhere on the path is rejected. Artifact repositories
|
|
101
|
+
* are created solely by Yui and never legitimately contain symlinks, so this
|
|
102
|
+
* strict policy blocks read/write-through-symlink escapes without false
|
|
103
|
+
* positives.
|
|
104
|
+
*
|
|
105
|
+
* Returns the absolute path. Does not create anything.
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveContainedArtifactPath(repoRoot, relativePath) {
|
|
108
|
+
const canonicalRoot = resolve(repoRoot);
|
|
109
|
+
const safeRelative = safeRelativeArtifactPath(relativePath);
|
|
110
|
+
const absolute = resolve(canonicalRoot, safeRelative);
|
|
111
|
+
// Lexical containment: the resolved path must remain within the repo root.
|
|
112
|
+
const fromRoot = relative(canonicalRoot, absolute);
|
|
113
|
+
if (fromRoot.length === 0 || fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
114
|
+
throw new Error("Artifact relativePath escapes its repository.");
|
|
115
|
+
}
|
|
116
|
+
// Symlink-escape guard: walk each component from the root down; reject the
|
|
117
|
+
// first symbolic link. `.git` is never a valid artifact ancestor either.
|
|
118
|
+
const segments = safeRelative.split("/");
|
|
119
|
+
let current = canonicalRoot;
|
|
120
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
121
|
+
const segment = segments[index];
|
|
122
|
+
if (segment === ".git") {
|
|
123
|
+
throw new Error("Artifact relativePath may not enter the Git control directory.");
|
|
124
|
+
}
|
|
125
|
+
current = join(current, segment);
|
|
126
|
+
const kind = await pathKind(current);
|
|
127
|
+
if (kind === "symlink") {
|
|
128
|
+
throw new Error("Artifact relativePath resolves through a symbolic link.");
|
|
129
|
+
}
|
|
130
|
+
// An intermediate component that exists but is not a directory is invalid.
|
|
131
|
+
if (kind === "file" && index < segments.length - 1) {
|
|
132
|
+
throw new Error("Artifact relativePath descends into a file.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return absolute;
|
|
136
|
+
}
|
|
137
|
+
async function pathKind(path) {
|
|
138
|
+
try {
|
|
139
|
+
const entry = await lstat(path);
|
|
140
|
+
if (entry.isSymbolicLink())
|
|
141
|
+
return "symlink";
|
|
142
|
+
if (entry.isDirectory())
|
|
143
|
+
return "directory";
|
|
144
|
+
return "file";
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
148
|
+
return undefined;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { openTaskArtifactRepository } from "./taskArtifactRepository.js";
|
|
2
|
+
import { requireCommitId } from "./managedGit.js";
|
|
3
|
+
import { safeRelativeArtifactPath } from "./artifactPaths.js";
|
|
4
|
+
/** Compact string form for reference lists that are plain strings (completion refs). */
|
|
5
|
+
const GIT_ARTIFACT_REF_PREFIX = "git:";
|
|
6
|
+
/**
|
|
7
|
+
* Validate a {@link GitArtifactRef}'s shape (PURE; no I/O). Safe to call inside
|
|
8
|
+
* a synchronous DB transaction and inside the synchronous context pipeline.
|
|
9
|
+
* Returns a normalized copy (canonical POSIX relativePath, lowercased commit).
|
|
10
|
+
*/
|
|
11
|
+
export function validateGitArtifactRef(ref) {
|
|
12
|
+
if (!ref || typeof ref !== "object")
|
|
13
|
+
throw new Error("Git artifact ref must be an object.");
|
|
14
|
+
if (typeof ref.taskId !== "string" || ref.taskId.trim().length === 0) {
|
|
15
|
+
throw new Error("Git artifact ref requires a taskId.");
|
|
16
|
+
}
|
|
17
|
+
const commit = requireCommitId(ref.commit);
|
|
18
|
+
const relativePath = safeRelativeArtifactPath(ref.relativePath);
|
|
19
|
+
if (ref.digest !== undefined && !/^[a-f0-9]{64}$/u.test(ref.digest)) {
|
|
20
|
+
throw new Error("Git artifact ref digest must be a sha256 hex string.");
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
taskId: ref.taskId,
|
|
24
|
+
commit,
|
|
25
|
+
relativePath,
|
|
26
|
+
...(ref.digest === undefined ? {} : { digest: ref.digest })
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** True when a plain string is a commit-pinned artifact reference. */
|
|
30
|
+
export function isGitArtifactRefString(value) {
|
|
31
|
+
return typeof value === "string" && value.startsWith(GIT_ARTIFACT_REF_PREFIX);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Format a same-Task pinned reference as `git:<commit>:<relativePath>`. The
|
|
35
|
+
* Task is implicit (the reference belongs to the referencing Task, e.g. a
|
|
36
|
+
* completion ref of the completing Task). The digest is omitted: the commit is
|
|
37
|
+
* the freeze, so the string form is already self-certifying.
|
|
38
|
+
*/
|
|
39
|
+
export function formatGitArtifactRef(ref) {
|
|
40
|
+
const valid = validateGitArtifactRef(ref);
|
|
41
|
+
return `${GIT_ARTIFACT_REF_PREFIX}${valid.commit}:${valid.relativePath}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parse `git:<commit>:<relativePath>` for a given Task. Rejects any malformed
|
|
45
|
+
* form. PURE; no I/O. `commit` is a full object id and never contains a colon,
|
|
46
|
+
* so the first colon after the prefix splits commit from path unambiguously.
|
|
47
|
+
*/
|
|
48
|
+
export function parseGitArtifactRef(value, taskId) {
|
|
49
|
+
if (!isGitArtifactRefString(value)) {
|
|
50
|
+
throw new Error("Not a commit-pinned artifact reference.");
|
|
51
|
+
}
|
|
52
|
+
const body = value.slice(GIT_ARTIFACT_REF_PREFIX.length);
|
|
53
|
+
const separator = body.indexOf(":");
|
|
54
|
+
if (separator <= 0 || separator === body.length - 1) {
|
|
55
|
+
throw new Error("Commit-pinned reference must be git:<commit>:<relativePath>.");
|
|
56
|
+
}
|
|
57
|
+
return validateGitArtifactRef({
|
|
58
|
+
taskId,
|
|
59
|
+
commit: body.slice(0, separator),
|
|
60
|
+
relativePath: body.slice(separator + 1)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a pinned reference to its frozen bytes (ASYNC). Reads the exact
|
|
65
|
+
* commit/path from the Task's local repository and returns bytes + the actual
|
|
66
|
+
* sha256. When the reference records a `digest`, a mismatch is a drift error —
|
|
67
|
+
* though for a correctly pinned commit this can only happen if the recorded
|
|
68
|
+
* digest was wrong, since the commit freezes the bytes.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveGitArtifact(home, ref) {
|
|
71
|
+
const valid = validateGitArtifactRef(ref);
|
|
72
|
+
const repo = openTaskArtifactRepository(home, valid.taskId);
|
|
73
|
+
if (!repo.exists()) {
|
|
74
|
+
throw new Error(`Artifact repository is unavailable for ${valid.taskId}.`);
|
|
75
|
+
}
|
|
76
|
+
const content = await repo.read(valid.relativePath, valid.commit);
|
|
77
|
+
if (valid.digest !== undefined && content.digest !== valid.digest) {
|
|
78
|
+
throw new Error(`Frozen artifact drifted from its recorded digest: ${valid.taskId}@${valid.commit}:${valid.relativePath}.`);
|
|
79
|
+
}
|
|
80
|
+
return content;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Save one file artifact and return its frozen pinned reference (ASYNC).
|
|
84
|
+
*
|
|
85
|
+
* This is the primitive behind the `artifact.save` capability: write `bytes`
|
|
86
|
+
* to `relativePath` in the Task's local repository, commit exactly that path
|
|
87
|
+
* (one meaningful update = one commit), and return the commit-pinned reference
|
|
88
|
+
* for the resulting state. The returned reference is self-certifying, so a
|
|
89
|
+
* caller can record it directly as frozen evidence.
|
|
90
|
+
*/
|
|
91
|
+
export async function saveArtifactFile(home, taskId, input) {
|
|
92
|
+
const repo = openTaskArtifactRepository(home, taskId);
|
|
93
|
+
const relativePath = safeRelativeArtifactPath(input.relativePath);
|
|
94
|
+
const result = await repo.save({
|
|
95
|
+
files: [{ relativePath, bytes: input.bytes }],
|
|
96
|
+
message: input.message ?? `save ${relativePath}`,
|
|
97
|
+
...(input.expectedHead === undefined ? {} : { expectedHead: input.expectedHead })
|
|
98
|
+
});
|
|
99
|
+
// Read back at the resulting commit to record the exact frozen digest.
|
|
100
|
+
const content = await repo.read(relativePath, result.commit);
|
|
101
|
+
return validateGitArtifactRef({
|
|
102
|
+
taskId,
|
|
103
|
+
commit: result.commit,
|
|
104
|
+
relativePath: content.relativePath,
|
|
105
|
+
digest: content.digest
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function readCurrentArtifact(home, taskId, relativePath) {
|
|
109
|
+
const repo = openTaskArtifactRepository(home, taskId);
|
|
110
|
+
if (!repo.exists())
|
|
111
|
+
throw new Error(`Artifact repository is unavailable for ${taskId}.`);
|
|
112
|
+
return repo.read(safeRelativeArtifactPath(relativePath));
|
|
113
|
+
}
|
|
114
|
+
/** Ordinary listing: tracked artifacts at HEAD (ASYNC, not frozen). Empty when no repo yet. */
|
|
115
|
+
export async function listCurrentArtifacts(home, taskId) {
|
|
116
|
+
const repo = openTaskArtifactRepository(home, taskId);
|
|
117
|
+
if (!repo.exists())
|
|
118
|
+
return [];
|
|
119
|
+
return repo.list();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Pin the CURRENT HEAD state of one or more relativePaths into frozen
|
|
123
|
+
* references (ASYNC). Used at Candidate submission / completion time to capture
|
|
124
|
+
* exactly what exists now. Fails if any path is not tracked at HEAD.
|
|
125
|
+
*/
|
|
126
|
+
export async function pinCurrentArtifacts(home, taskId, relativePaths) {
|
|
127
|
+
const repo = openTaskArtifactRepository(home, taskId);
|
|
128
|
+
if (!repo.exists())
|
|
129
|
+
throw new Error(`Artifact repository is unavailable for ${taskId}.`);
|
|
130
|
+
const head = await repo.head();
|
|
131
|
+
if (head === null)
|
|
132
|
+
throw new Error(`Artifact repository has no commits for ${taskId}.`);
|
|
133
|
+
const refs = [];
|
|
134
|
+
for (const relativePath of relativePaths) {
|
|
135
|
+
// Reading at the pinned commit both proves the path exists there and yields
|
|
136
|
+
// the exact digest to record for display/fast verification.
|
|
137
|
+
const content = await repo.read(safeRelativeArtifactPath(relativePath), head);
|
|
138
|
+
refs.push(validateGitArtifactRef({
|
|
139
|
+
taskId,
|
|
140
|
+
commit: head,
|
|
141
|
+
relativePath: content.relativePath,
|
|
142
|
+
digest: content.digest
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
return refs;
|
|
146
|
+
}
|