@stdd/plugin 0.9.0
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/.claude-plugin/plugin.json +9 -0
- package/.codex-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/extensions/stdd.mjs +77 -0
- package/hooks/claude-hooks.json +28 -0
- package/hooks/codex-hooks.json +28 -0
- package/package.json +38 -0
- package/runtime/adapters/README.md +158 -0
- package/runtime/cli/check.mjs +555 -0
- package/runtime/cli/ci.mjs +190 -0
- package/runtime/cli/claude-hooks.mjs +689 -0
- package/runtime/cli/config.mjs +27 -0
- package/runtime/cli/evidence.mjs +249 -0
- package/runtime/cli/generated-files.mjs +1693 -0
- package/runtime/cli/held-fs.mjs +415 -0
- package/runtime/cli/init.mjs +883 -0
- package/runtime/cli/ledger.mjs +1470 -0
- package/runtime/cli/lib.mjs +909 -0
- package/runtime/cli/path-bytes.mjs +83 -0
- package/runtime/cli/policy.mjs +112 -0
- package/runtime/cli/recorders.mjs +188 -0
- package/runtime/cli/review-fs.mjs +825 -0
- package/runtime/cli/review.mjs +1065 -0
- package/runtime/cli/runtime.mjs +32 -0
- package/runtime/cli/scope.mjs +185 -0
- package/runtime/cli/snapshot.mjs +897 -0
- package/runtime/cli/state-validation.mjs +168 -0
- package/runtime/cli/status.mjs +580 -0
- package/runtime/cli/stdd.mjs +536 -0
- package/runtime/cli/worker-fs.mjs +971 -0
- package/runtime/cli/worker-metadata.mjs +139 -0
- package/runtime/cli/worker.mjs +779 -0
- package/runtime/method/README.md +634 -0
- package/runtime/method/reference-commands.md +147 -0
- package/runtime/method/reference-generated-state.md +151 -0
- package/runtime/method/reference-integration.md +233 -0
- package/runtime/package.json +65 -0
- package/runtime/playbooks/brainstorming.md +46 -0
- package/runtime/playbooks/debugging.md +36 -0
- package/runtime/playbooks/delegate-slice.md +129 -0
- package/runtime/playbooks/finish-change.md +46 -0
- package/runtime/playbooks/implement.md +26 -0
- package/runtime/playbooks/investigation.md +33 -0
- package/runtime/playbooks/managed-playbooks.json +14 -0
- package/runtime/playbooks/planning.md +177 -0
- package/runtime/playbooks/pr-green.md +50 -0
- package/runtime/playbooks/start-change.md +37 -0
- package/runtime/playbooks/worktrees.md +45 -0
- package/runtime/prebuilds/stdd-fs/darwin-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/darwin-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-arm64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/linux-x64/stdd-fs +0 -0
- package/runtime/prebuilds/stdd-fs/manifest.json +47 -0
- package/runtime/prebuilds/stdd-fs/win32-arm64/stdd-fs.exe +0 -0
- package/runtime/prebuilds/stdd-fs/win32-x64/stdd-fs.exe +0 -0
- package/runtime/sdk/adapters.mjs +279 -0
- package/runtime/sdk/file-observation.mjs +12 -0
- package/runtime/sdk/index.d.ts +140 -0
- package/runtime/sdk/index.mjs +31 -0
- package/runtime/sdk/native-fs.mjs +1235 -0
- package/runtime/sdk/path.mjs +71 -0
- package/runtime/sdk/text.mjs +42 -0
- package/runtime/sdk/workflow.mjs +294 -0
- package/runtime/templates/deferred-design.md +47 -0
- package/runtime/templates/github-stdd.yml +42 -0
- package/runtime/templates/gitlab-stdd.yml +72 -0
- package/runtime/templates/pr-description.md +35 -0
- package/scripts/adopting-root.mjs +42 -0
- package/scripts/stdd-hook.mjs +72 -0
- package/skills/stdd-brainstorming/SKILL.md +48 -0
- package/skills/stdd-debugging/SKILL.md +38 -0
- package/skills/stdd-delegate-slice/SKILL.md +118 -0
- package/skills/stdd-finish-change/SKILL.md +40 -0
- package/skills/stdd-implement/SKILL.md +28 -0
- package/skills/stdd-investigation/SKILL.md +35 -0
- package/skills/stdd-planning/SKILL.md +165 -0
- package/skills/stdd-pr-green/SKILL.md +52 -0
- package/skills/stdd-start-change/SKILL.md +39 -0
- package/skills/stdd-worktrees/SKILL.md +46 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
// --- managed gitless workers: portable native create and collect ---
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isPrintableSingleLine } from "../sdk/text.mjs";
|
|
6
|
+
import { openNativeRepoMutation } from "./held-fs.mjs";
|
|
7
|
+
import {
|
|
8
|
+
currentBranch,
|
|
9
|
+
isStateExemptPath,
|
|
10
|
+
isStateLedgerEvent,
|
|
11
|
+
ledgerAppendContext,
|
|
12
|
+
loadLedger,
|
|
13
|
+
mutateLedgerWithNativeSession,
|
|
14
|
+
parseStateLedger,
|
|
15
|
+
sameTaskBoundary,
|
|
16
|
+
scopeLedgerForCheckout,
|
|
17
|
+
} from "./ledger.mjs";
|
|
18
|
+
import { sha256 } from "./lib.mjs";
|
|
19
|
+
import { splitNul } from "./path-bytes.mjs";
|
|
20
|
+
import { fail, MAX_SUBPROCESS_BUFFER } from "./runtime.mjs";
|
|
21
|
+
import { validateScopeDeclaration, workerScopeViolations } from "./scope.mjs";
|
|
22
|
+
import { isPlainLedgerRecord } from "./state-validation.mjs";
|
|
23
|
+
import {
|
|
24
|
+
preflightPrivateWorkerQuarantine,
|
|
25
|
+
preflightWorkerCreationState,
|
|
26
|
+
preflightWorkerParent,
|
|
27
|
+
publishWorkerFile,
|
|
28
|
+
publishWorkerSymlink,
|
|
29
|
+
quarantineWorkerDeletion,
|
|
30
|
+
readNativeWorkerPath,
|
|
31
|
+
readWorkerDeletionQuarantineState,
|
|
32
|
+
sameWorkerState,
|
|
33
|
+
stateWithPortableIdentity,
|
|
34
|
+
WORKER_DELETIONS_REL,
|
|
35
|
+
workerViewPath,
|
|
36
|
+
writeNewWorkerPath,
|
|
37
|
+
} from "./worker-fs.mjs";
|
|
38
|
+
import {
|
|
39
|
+
createWorkerId,
|
|
40
|
+
parseWorkerMetadata,
|
|
41
|
+
readWorkerMetadata,
|
|
42
|
+
WORKER_EVIDENCE_EVENTS,
|
|
43
|
+
WORKER_METADATA_REL,
|
|
44
|
+
WORKER_METADATA_SCHEMA,
|
|
45
|
+
} from "./worker-metadata.mjs";
|
|
46
|
+
|
|
47
|
+
function workerVisiblePaths(cwd) {
|
|
48
|
+
const output = splitNul(
|
|
49
|
+
execFileSync("git", ["-C", cwd, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
50
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
51
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
const paths = [];
|
|
55
|
+
for (const raw of output) {
|
|
56
|
+
if (raw.length === 0) continue;
|
|
57
|
+
const relative = raw.toString("utf8");
|
|
58
|
+
if (!Buffer.from(relative, "utf8").equals(raw)) {
|
|
59
|
+
fail("worker create does not support a non-UTF-8 Git path");
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
relative.split("/").includes(".git") ||
|
|
63
|
+
isStateExemptPath(cwd, relative) ||
|
|
64
|
+
relative === WORKER_METADATA_REL
|
|
65
|
+
) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
path.posix.isAbsolute(relative) ||
|
|
70
|
+
relative.includes("\\") ||
|
|
71
|
+
relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
72
|
+
) {
|
|
73
|
+
fail(`unsafe worker source path ${workerViewPath(relative)}`);
|
|
74
|
+
}
|
|
75
|
+
paths.push(relative);
|
|
76
|
+
}
|
|
77
|
+
return [...new Set(paths)].sort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function trackedModes(cwd) {
|
|
81
|
+
const result = new Map();
|
|
82
|
+
for (const record of splitNul(
|
|
83
|
+
execFileSync("git", ["-C", cwd, "ls-files", "--stage", "-z"], {
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
maxBuffer: MAX_SUBPROCESS_BUFFER,
|
|
86
|
+
}),
|
|
87
|
+
)) {
|
|
88
|
+
const tab = record.indexOf(0x09);
|
|
89
|
+
if (tab === -1) continue;
|
|
90
|
+
const header = record.subarray(0, tab).toString("ascii");
|
|
91
|
+
const relative = record.subarray(tab + 1).toString("utf8");
|
|
92
|
+
const mode = header.slice(0, 6) === "100755" ? 0o755 : 0o644;
|
|
93
|
+
result.set(relative, mode);
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function workerSourceHead(cwd) {
|
|
99
|
+
return execFileSync("git", ["-C", cwd, "rev-parse", "HEAD"], {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
102
|
+
}).trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function workerCollectionContextError(cwd, metadata, expectedContext) {
|
|
106
|
+
const branch = currentBranch(cwd);
|
|
107
|
+
if (branch !== metadata.source.branch) {
|
|
108
|
+
return `bound worker branch changed: expected ${metadata.source.branch}, found ${branch ?? "none"}`;
|
|
109
|
+
}
|
|
110
|
+
const liveContext = ledgerAppendContext(cwd, { event: "note" });
|
|
111
|
+
if (
|
|
112
|
+
!liveContext.task ||
|
|
113
|
+
liveContext.task.id !== metadata.source.taskId ||
|
|
114
|
+
!sameTaskBoundary(liveContext.taskState, expectedContext.taskState)
|
|
115
|
+
) {
|
|
116
|
+
return "bound worker active task changed or is no longer active";
|
|
117
|
+
}
|
|
118
|
+
if (workerSourceHead(cwd) !== metadata.source.head) return "bound worker source HEAD changed";
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function assertWorkerCollectionContext(cwd, metadata, expectedContext) {
|
|
123
|
+
const error = workerCollectionContextError(cwd, metadata, expectedContext);
|
|
124
|
+
if (error !== null) throw new Error(error);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function contextForRoot(context, root, rootPath) {
|
|
128
|
+
return { session: context.session, root, rootPath, close: context.close };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function samePortableIdentity(left, right) {
|
|
132
|
+
return (
|
|
133
|
+
left?.version === right?.version &&
|
|
134
|
+
left?.platform === right?.platform &&
|
|
135
|
+
left?.volume === right?.volume &&
|
|
136
|
+
left?.fileId === right?.fileId &&
|
|
137
|
+
left?.kind === right?.kind
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function listNativeDirectory(context, directory) {
|
|
142
|
+
const entries = [];
|
|
143
|
+
let cursor = null;
|
|
144
|
+
do {
|
|
145
|
+
const page = await context.session.list(directory.cap, { cursor, limit: 512 });
|
|
146
|
+
entries.push(...page.entries);
|
|
147
|
+
cursor = page.cursor;
|
|
148
|
+
} while (cursor !== null);
|
|
149
|
+
return entries;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function gitIgnoredPaths(cwd, relatives) {
|
|
153
|
+
if (relatives.length === 0) return new Set();
|
|
154
|
+
const ignored = new Set();
|
|
155
|
+
const ordinary = relatives.filter((relative) => !/[\r\n]/.test(relative));
|
|
156
|
+
if (ordinary.length > 0) {
|
|
157
|
+
try {
|
|
158
|
+
const output = execFileSync(
|
|
159
|
+
"git",
|
|
160
|
+
["-C", cwd, "-c", "core.quotePath=false", "check-ignore", "--no-index", "--", ...ordinary],
|
|
161
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: MAX_SUBPROCESS_BUFFER },
|
|
162
|
+
);
|
|
163
|
+
for (const relative of output.split("\n").filter(Boolean)) ignored.add(relative);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.status !== 1) throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const relative of relatives.filter((candidate) => /[\r\n]/.test(candidate))) {
|
|
169
|
+
try {
|
|
170
|
+
execFileSync("git", ["-C", cwd, "check-ignore", "--no-index", "-q", "--", relative], {
|
|
171
|
+
stdio: "ignore",
|
|
172
|
+
});
|
|
173
|
+
ignored.add(relative);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error.status !== 1) throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return ignored;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function nativeWorkerCurrentStates(context, metadata, sourceCwd) {
|
|
182
|
+
const baselinePaths = Object.keys(metadata.baseline.files);
|
|
183
|
+
const baselinePrefixes = new Set();
|
|
184
|
+
for (const baseline of baselinePaths) {
|
|
185
|
+
let prefix = path.posix.dirname(baseline);
|
|
186
|
+
while (prefix !== ".") {
|
|
187
|
+
baselinePrefixes.add(prefix);
|
|
188
|
+
prefix = path.posix.dirname(prefix);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const discovered = new Set();
|
|
192
|
+
const walk = async (directory, prefix = "") => {
|
|
193
|
+
const entries = await listNativeDirectory(context, directory);
|
|
194
|
+
const relatives = entries.map((entry) => (prefix ? `${prefix}/${entry.name}` : entry.name));
|
|
195
|
+
const ignored = gitIgnoredPaths(sourceCwd, relatives);
|
|
196
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
197
|
+
const entry = entries[index];
|
|
198
|
+
const relative = relatives[index];
|
|
199
|
+
if (!isPrintableSingleLine(entry.name)) {
|
|
200
|
+
throw new Error(`worker path ${workerViewPath(relative)} contains unsupported characters`);
|
|
201
|
+
}
|
|
202
|
+
const carriesBaseline =
|
|
203
|
+
Object.hasOwn(metadata.baseline.files, relative) || baselinePrefixes.has(relative);
|
|
204
|
+
if (ignored.has(relative) && !carriesBaseline) continue;
|
|
205
|
+
if (relative.split("/").includes(".git")) {
|
|
206
|
+
throw new Error(`managed worker sandbox must not contain .git: ${workerViewPath(relative)}`);
|
|
207
|
+
}
|
|
208
|
+
if (isStateExemptPath(metadata.root, relative) || relative === WORKER_METADATA_REL) continue;
|
|
209
|
+
if (entry.observation.identity.kind === "directory") {
|
|
210
|
+
const child = await context.session.openChild(directory.cap, entry.name);
|
|
211
|
+
try {
|
|
212
|
+
await walk(child, relative);
|
|
213
|
+
} finally {
|
|
214
|
+
await context.session.closeCapability(child.cap).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
discovered.add(relative);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
await walk(context.root);
|
|
222
|
+
const states = Object.create(null);
|
|
223
|
+
const observations = Object.create(null);
|
|
224
|
+
for (const relative of new Set([...baselinePaths, ...discovered])) {
|
|
225
|
+
const baseline = metadata.baseline.files[relative];
|
|
226
|
+
const hint = baseline?.mode ?? 0o644;
|
|
227
|
+
const result = await readNativeWorkerPath(context, relative, {
|
|
228
|
+
modeHint: hint,
|
|
229
|
+
legacyMode: metadata.schema === 1 ? (baseline?.mode ?? null) : null,
|
|
230
|
+
});
|
|
231
|
+
states[relative] = result.state;
|
|
232
|
+
observations[relative] = result.observation;
|
|
233
|
+
if (
|
|
234
|
+
metadata.schema === 2 &&
|
|
235
|
+
baseline !== undefined &&
|
|
236
|
+
sameWorkerState(result.state, baseline) &&
|
|
237
|
+
!samePortableIdentity(result.observation?.identity, baseline.portable.sandbox)
|
|
238
|
+
) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`worker path ${workerViewPath(relative)} identity changed without a content change`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (states[relative] === null) {
|
|
244
|
+
delete states[relative];
|
|
245
|
+
delete observations[relative];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { states, observations };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function openAdditionalRoot(context, absolute, label) {
|
|
252
|
+
if (!path.isAbsolute(absolute)) throw new Error(`${label} must be absolute`);
|
|
253
|
+
const root = await context.session.openRoot(absolute);
|
|
254
|
+
await context.session.probe(root.cap);
|
|
255
|
+
return contextForRoot(context, root, absolute);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function nativeWorkerMetadata(context, sandbox) {
|
|
259
|
+
const result = await readNativeWorkerPath(context, WORKER_METADATA_REL, {
|
|
260
|
+
bytes: true,
|
|
261
|
+
modeHint: 0o600,
|
|
262
|
+
});
|
|
263
|
+
if (result.state === null) throw new Error("not a managed gitless worker sandbox");
|
|
264
|
+
return parseWorkerMetadata(
|
|
265
|
+
result.bytes.toString("utf8"),
|
|
266
|
+
sandbox,
|
|
267
|
+
path.join(sandbox, ...WORKER_METADATA_REL.split("/")),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function ledgerRecord(event, branch) {
|
|
272
|
+
return JSON.stringify({ ts: new Date().toISOString(), ...event, branch });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function workerCreate(cwd, destinationInput, frozenPaths, allowedPaths, dependencies = {}) {
|
|
276
|
+
validateScopeDeclaration("worker create", frozenPaths, allowedPaths);
|
|
277
|
+
const context = ledgerAppendContext(cwd, { event: "worker-create" });
|
|
278
|
+
if (!context.task) fail('worker create needs an active task — run `stdd task start "<name>"`');
|
|
279
|
+
const scoped = scopeLedgerForCheckout(cwd, context.branch);
|
|
280
|
+
const docs = scoped.events.filter((event) => event.event === "docs").at(-1);
|
|
281
|
+
if (!docs) fail("worker create needs a recorded docs decision");
|
|
282
|
+
const destinationLexical = path.resolve(cwd, destinationInput);
|
|
283
|
+
let destinationParent;
|
|
284
|
+
try {
|
|
285
|
+
destinationParent = fs.realpathSync(path.dirname(destinationLexical));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
fail(`worker destination parent is unavailable: ${error.message}`);
|
|
288
|
+
}
|
|
289
|
+
const destination = path.join(destinationParent, path.basename(destinationLexical));
|
|
290
|
+
const source = fs.realpathSync(cwd);
|
|
291
|
+
if (destination === source || destination.startsWith(`${source}${path.sep}`)) {
|
|
292
|
+
fail("worker destination must be outside the source checkout");
|
|
293
|
+
}
|
|
294
|
+
let enclosingGitRoot = null;
|
|
295
|
+
try {
|
|
296
|
+
enclosingGitRoot = fs.realpathSync(
|
|
297
|
+
execFileSync("git", ["-C", destinationParent, "rev-parse", "--show-toplevel"], {
|
|
298
|
+
encoding: "utf8",
|
|
299
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
300
|
+
}).trim(),
|
|
301
|
+
);
|
|
302
|
+
} catch {}
|
|
303
|
+
if (
|
|
304
|
+
enclosingGitRoot &&
|
|
305
|
+
(destination === enclosingGitRoot || destination.startsWith(`${enclosingGitRoot}${path.sep}`))
|
|
306
|
+
) {
|
|
307
|
+
fail("worker destination must not be inside any Git checkout");
|
|
308
|
+
}
|
|
309
|
+
const head = workerSourceHead(cwd);
|
|
310
|
+
const visible = workerVisiblePaths(cwd);
|
|
311
|
+
const modes = trackedModes(cwd);
|
|
312
|
+
const untracked = visible.filter((relative) => !modes.has(relative)).length;
|
|
313
|
+
const workerId = createWorkerId();
|
|
314
|
+
let mutation;
|
|
315
|
+
let ownsDestination = false;
|
|
316
|
+
try {
|
|
317
|
+
const openMutation = dependencies.openNativeRepoMutation ?? openNativeRepoMutation;
|
|
318
|
+
mutation = await openMutation(source, "worker creation native filesystem helper");
|
|
319
|
+
const destinationParentContext = await openAdditionalRoot(
|
|
320
|
+
mutation,
|
|
321
|
+
destinationParent,
|
|
322
|
+
"worker destination parent",
|
|
323
|
+
);
|
|
324
|
+
const destinationName = path.basename(destination);
|
|
325
|
+
try {
|
|
326
|
+
await mutation.session.stat(destinationParentContext.root.cap, destinationName);
|
|
327
|
+
throw new Error("worker destination must not exist");
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error?.code !== "not-found") throw error;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Complete source preflight, including every mode and symlink target,
|
|
333
|
+
// before the destination namespace receives its first inode.
|
|
334
|
+
const prepared = [];
|
|
335
|
+
for (const relative of visible) {
|
|
336
|
+
const result = await readNativeWorkerPath(mutation, relative, {
|
|
337
|
+
bytes: true,
|
|
338
|
+
modeHint: modes.get(relative) ?? 0o644,
|
|
339
|
+
});
|
|
340
|
+
if (result.state === null)
|
|
341
|
+
throw new Error(`worker source path ${workerViewPath(relative)} vanished`);
|
|
342
|
+
preflightWorkerCreationState(relative, result.state, null, result.observation.identity.platform);
|
|
343
|
+
prepared.push({ relative, result });
|
|
344
|
+
}
|
|
345
|
+
if (prepared.some(({ result }) => result.state.type === "symlink")) {
|
|
346
|
+
await mutation.session.preflightSymlink(destinationParentContext.root.cap);
|
|
347
|
+
}
|
|
348
|
+
const destinationRoot = await mutation.session.createDirectory(
|
|
349
|
+
destinationParentContext.root.cap,
|
|
350
|
+
destinationName,
|
|
351
|
+
0o700,
|
|
352
|
+
);
|
|
353
|
+
ownsDestination = true;
|
|
354
|
+
await mutation.session.flush(
|
|
355
|
+
destinationParentContext.root.cap,
|
|
356
|
+
"namespace",
|
|
357
|
+
destinationParentContext.root.observation.identity,
|
|
358
|
+
);
|
|
359
|
+
const destinationContext = contextForRoot(mutation, destinationRoot, destination);
|
|
360
|
+
const files = Object.create(null);
|
|
361
|
+
for (const item of prepared) {
|
|
362
|
+
const written = await writeNewWorkerPath(destinationContext, item.relative, item.result);
|
|
363
|
+
files[item.relative] = stateWithPortableIdentity(
|
|
364
|
+
item.result.state,
|
|
365
|
+
item.result.observation,
|
|
366
|
+
written.observation,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const metadata = {
|
|
370
|
+
schema: WORKER_METADATA_SCHEMA,
|
|
371
|
+
workerId,
|
|
372
|
+
source: {
|
|
373
|
+
root: source,
|
|
374
|
+
branch: context.branch,
|
|
375
|
+
taskId: context.task.id,
|
|
376
|
+
taskName: context.task.name,
|
|
377
|
+
head,
|
|
378
|
+
},
|
|
379
|
+
scope: { frozenPaths, allowedPaths },
|
|
380
|
+
baseline: { files },
|
|
381
|
+
};
|
|
382
|
+
const metadataBytes = Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`);
|
|
383
|
+
const bootstrap = [
|
|
384
|
+
{
|
|
385
|
+
ts: new Date().toISOString(),
|
|
386
|
+
event: "task-start",
|
|
387
|
+
id: context.task.id,
|
|
388
|
+
name: context.task.name,
|
|
389
|
+
planBaseline: null,
|
|
390
|
+
branch: context.branch,
|
|
391
|
+
},
|
|
392
|
+
{ ...docs, ts: new Date().toISOString(), branch: context.branch, taskId: context.task.id },
|
|
393
|
+
{
|
|
394
|
+
ts: new Date().toISOString(),
|
|
395
|
+
event: "scope",
|
|
396
|
+
frozenPaths,
|
|
397
|
+
allowedPaths,
|
|
398
|
+
baseline: { head, dirty: {} },
|
|
399
|
+
branch: context.branch,
|
|
400
|
+
taskId: context.task.id,
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
for (const [relative, bytes] of [
|
|
404
|
+
[WORKER_METADATA_REL, metadataBytes],
|
|
405
|
+
[".stdd/ledger.jsonl", Buffer.from(`${bootstrap.map(JSON.stringify).join("\n")}\n`)],
|
|
406
|
+
]) {
|
|
407
|
+
await writeNewWorkerPath(destinationContext, relative, {
|
|
408
|
+
state: { type: "file", mode: 0o600, hash: sha256(bytes) },
|
|
409
|
+
bytes,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
currentBranch(cwd) !== context.branch ||
|
|
414
|
+
workerSourceHead(cwd) !== head ||
|
|
415
|
+
!sameTaskBoundary(ledgerAppendContext(cwd, { event: "note" }).taskState, context.taskState)
|
|
416
|
+
) {
|
|
417
|
+
throw new Error("source checkout changed during worker creation");
|
|
418
|
+
}
|
|
419
|
+
await mutateLedgerWithNativeSession(mutation, [
|
|
420
|
+
ledgerRecord(
|
|
421
|
+
{
|
|
422
|
+
event: "worker-create",
|
|
423
|
+
workerId,
|
|
424
|
+
metadataHash: sha256(metadataBytes),
|
|
425
|
+
sourceHead: head,
|
|
426
|
+
taskId: context.task.id,
|
|
427
|
+
},
|
|
428
|
+
context.branch,
|
|
429
|
+
),
|
|
430
|
+
]);
|
|
431
|
+
console.log(
|
|
432
|
+
`stdd worker: gitless worker ${workerId} created at ${destination} ` +
|
|
433
|
+
`(${visible.length} files, ${untracked} untracked)`,
|
|
434
|
+
);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`${error.message}${
|
|
438
|
+
ownsDestination
|
|
439
|
+
? ` — partial sandbox remains at ${destination}; inspect and remove it explicitly`
|
|
440
|
+
: ""
|
|
441
|
+
}`,
|
|
442
|
+
{ cause: error },
|
|
443
|
+
);
|
|
444
|
+
} finally {
|
|
445
|
+
if (mutation) await mutation.close().catch(() => {});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function workerCollect(cwd, sandboxInput, dependencies = {}) {
|
|
450
|
+
if (readWorkerMetadata(cwd)) {
|
|
451
|
+
fail("worker collect is source-checkout-owned and unavailable in a managed gitless worker");
|
|
452
|
+
}
|
|
453
|
+
const sandbox = path.resolve(cwd, sandboxInput);
|
|
454
|
+
const source = fs.realpathSync(cwd);
|
|
455
|
+
let mutation;
|
|
456
|
+
try {
|
|
457
|
+
const openMutation = dependencies.openNativeRepoMutation ?? openNativeRepoMutation;
|
|
458
|
+
mutation = await openMutation(source, "worker collection native filesystem helper");
|
|
459
|
+
const sandboxContext = await openAdditionalRoot(mutation, sandbox, "worker sandbox root");
|
|
460
|
+
const metadata = await nativeWorkerMetadata(sandboxContext, sandbox);
|
|
461
|
+
if (source !== metadata.source.root) {
|
|
462
|
+
throw new Error("worker collect must run from the bound source checkout");
|
|
463
|
+
}
|
|
464
|
+
const branch = currentBranch(cwd);
|
|
465
|
+
if (branch !== metadata.source.branch) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`bound worker branch changed: expected ${metadata.source.branch}, found ${branch ?? "none"}`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
const collectionContext = ledgerAppendContext(cwd, { event: "note" });
|
|
471
|
+
if (!collectionContext.task || collectionContext.task.id !== metadata.source.taskId) {
|
|
472
|
+
throw new Error("bound worker active task changed or is no longer active");
|
|
473
|
+
}
|
|
474
|
+
if (workerSourceHead(cwd) !== metadata.source.head) {
|
|
475
|
+
throw new Error("bound worker source HEAD changed");
|
|
476
|
+
}
|
|
477
|
+
const sourceEvents = loadLedger(cwd, branch);
|
|
478
|
+
const binding = sourceEvents
|
|
479
|
+
.filter(
|
|
480
|
+
(event) =>
|
|
481
|
+
event.event === "worker-create" &&
|
|
482
|
+
event.workerId === metadata.workerId &&
|
|
483
|
+
event.taskId === metadata.source.taskId,
|
|
484
|
+
)
|
|
485
|
+
.at(-1);
|
|
486
|
+
if (!binding || binding.metadataHash !== sha256(metadata.metadataBytes)) {
|
|
487
|
+
throw new Error("worker metadata hash does not match its source-ledger binding");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const currentSnapshot = await nativeWorkerCurrentStates(sandboxContext, metadata, cwd);
|
|
491
|
+
const current = currentSnapshot.states;
|
|
492
|
+
const touched = [];
|
|
493
|
+
for (const relative of new Set([...Object.keys(metadata.baseline.files), ...Object.keys(current)])) {
|
|
494
|
+
const before = metadata.baseline.files[relative] ?? null;
|
|
495
|
+
const after = current[relative] ?? null;
|
|
496
|
+
if (!sameWorkerState(before, after)) touched.push({ relative, before, after });
|
|
497
|
+
}
|
|
498
|
+
const scopeViolation = workerScopeViolations(
|
|
499
|
+
metadata.scope,
|
|
500
|
+
touched.map((change) => change.relative),
|
|
501
|
+
)[0];
|
|
502
|
+
if (scopeViolation) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`worker scope violation: ${workerViewPath(scopeViolation.relative)} is ` +
|
|
505
|
+
(scopeViolation.kind === "frozen" ? "frozen" : "outside allowed paths"),
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
if (
|
|
509
|
+
touched.some((change) => change.before !== null && !sameWorkerState(change.before, change.after))
|
|
510
|
+
) {
|
|
511
|
+
try {
|
|
512
|
+
execFileSync(
|
|
513
|
+
"git",
|
|
514
|
+
["-C", cwd, "check-ignore", "--no-index", "-q", `${WORKER_DELETIONS_REL}/probe`],
|
|
515
|
+
{ stdio: "ignore" },
|
|
516
|
+
);
|
|
517
|
+
} catch {
|
|
518
|
+
throw new Error(
|
|
519
|
+
"worker deletion quarantine is not Git-ignored — rerun stdd init before collecting deletions",
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Complete publication preflight for every path and every evidence event
|
|
525
|
+
// before the source checkout receives its first namespace mutation.
|
|
526
|
+
if (touched.some((change) => change.after?.type === "symlink")) {
|
|
527
|
+
await mutation.session.preflightSymlink(mutation.root.cap);
|
|
528
|
+
}
|
|
529
|
+
const prepared = [];
|
|
530
|
+
for (const change of touched) {
|
|
531
|
+
const inheritedLegacyMode =
|
|
532
|
+
metadata.schema === 1 && change.before?.type === "file" ? change.before.mode : null;
|
|
533
|
+
preflightWorkerCreationState(change.relative, change.after, inheritedLegacyMode);
|
|
534
|
+
if (change.after !== null) await preflightWorkerParent(mutation, change.relative);
|
|
535
|
+
if (change.before !== null) {
|
|
536
|
+
await preflightPrivateWorkerQuarantine(mutation, change.relative, metadata.workerId);
|
|
537
|
+
}
|
|
538
|
+
const sourceResult = await readNativeWorkerPath(mutation, change.relative, {
|
|
539
|
+
modeHint: change.before?.mode ?? change.after?.mode ?? null,
|
|
540
|
+
legacyMode: metadata.schema === 1 ? (change.before?.mode ?? null) : null,
|
|
541
|
+
});
|
|
542
|
+
const quarantinedState = await readWorkerDeletionQuarantineState(
|
|
543
|
+
mutation,
|
|
544
|
+
change.relative,
|
|
545
|
+
metadata.workerId,
|
|
546
|
+
);
|
|
547
|
+
const sourcePrepared =
|
|
548
|
+
change.before !== null &&
|
|
549
|
+
sourceResult.state === null &&
|
|
550
|
+
sameWorkerState(quarantinedState, change.before);
|
|
551
|
+
if (
|
|
552
|
+
metadata.schema === 2 &&
|
|
553
|
+
change.before !== null &&
|
|
554
|
+
sameWorkerState(sourceResult.state, change.before) &&
|
|
555
|
+
!samePortableIdentity(sourceResult.observation?.identity, change.before.portable.source)
|
|
556
|
+
) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: source identity changed since sandbox creation`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (
|
|
562
|
+
!sameWorkerState(sourceResult.state, change.before) &&
|
|
563
|
+
!sameWorkerState(sourceResult.state, change.after) &&
|
|
564
|
+
!sourcePrepared
|
|
565
|
+
) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: source changed since sandbox creation`,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
let finalBytes = null;
|
|
571
|
+
if (change.after?.type === "file") {
|
|
572
|
+
const final = await readNativeWorkerPath(sandboxContext, change.relative, {
|
|
573
|
+
bytes: true,
|
|
574
|
+
modeHint: change.after.mode,
|
|
575
|
+
legacyMode: metadata.schema === 1 ? change.after.mode : null,
|
|
576
|
+
});
|
|
577
|
+
if (!sameWorkerState(final.state, change.after)) {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`worker path ${workerViewPath(change.relative)} changed during collection preflight`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
finalBytes = final.bytes;
|
|
583
|
+
}
|
|
584
|
+
prepared.push({
|
|
585
|
+
...change,
|
|
586
|
+
sourceState: sourceResult.state,
|
|
587
|
+
sourceObservation: sourceResult.observation,
|
|
588
|
+
sourcePrepared,
|
|
589
|
+
sandboxObservation: currentSnapshot.observations[change.relative] ?? null,
|
|
590
|
+
finalBytes,
|
|
591
|
+
finalSourceObservation: sameWorkerState(sourceResult.state, change.after)
|
|
592
|
+
? sourceResult.observation
|
|
593
|
+
: null,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const sandboxLedgerResult = await readNativeWorkerPath(sandboxContext, ".stdd/ledger.jsonl", {
|
|
598
|
+
bytes: true,
|
|
599
|
+
modeHint: 0o600,
|
|
600
|
+
});
|
|
601
|
+
const sandboxLedger =
|
|
602
|
+
sandboxLedgerResult.state === null
|
|
603
|
+
? []
|
|
604
|
+
: parseStateLedger(sandboxLedgerResult.bytes.toString("utf8"), metadata.source.branch);
|
|
605
|
+
const invalidIndex = sandboxLedger.findIndex(
|
|
606
|
+
(event) => !isPlainLedgerRecord(event) || !isStateLedgerEvent(event),
|
|
607
|
+
);
|
|
608
|
+
if (invalidIndex !== -1) throw new Error(`invalid event at worker ledger line ${invalidIndex + 1}`);
|
|
609
|
+
const workerEvents = sandboxLedger.filter(
|
|
610
|
+
(event) => event.taskId === metadata.source.taskId && WORKER_EVIDENCE_EVENTS.has(event.event),
|
|
611
|
+
);
|
|
612
|
+
const imported = new Set(
|
|
613
|
+
sourceEvents
|
|
614
|
+
.filter(
|
|
615
|
+
(event) => event.workerId === metadata.workerId && typeof event.workerEventHash === "string",
|
|
616
|
+
)
|
|
617
|
+
.map((event) => event.workerEventHash),
|
|
618
|
+
);
|
|
619
|
+
const pendingEvidence = [];
|
|
620
|
+
for (const workerEvent of workerEvents) {
|
|
621
|
+
const workerEventHash = sha256(JSON.stringify(workerEvent));
|
|
622
|
+
if (!imported.has(workerEventHash)) {
|
|
623
|
+
pendingEvidence.push({ workerEvent, workerEventHash });
|
|
624
|
+
imported.add(workerEventHash);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const assertFinalStates = async () => {
|
|
629
|
+
for (const change of prepared) {
|
|
630
|
+
const sandboxLive = await readNativeWorkerPath(sandboxContext, change.relative, {
|
|
631
|
+
modeHint: change.after?.mode ?? null,
|
|
632
|
+
legacyMode: metadata.schema === 1 ? (change.after?.mode ?? null) : null,
|
|
633
|
+
});
|
|
634
|
+
if (
|
|
635
|
+
!sameWorkerState(sandboxLive.state, change.after) ||
|
|
636
|
+
!samePortableIdentity(sandboxLive.observation?.identity, change.sandboxObservation?.identity)
|
|
637
|
+
) {
|
|
638
|
+
throw new Error(`worker path ${workerViewPath(change.relative)} changed before final sweep`);
|
|
639
|
+
}
|
|
640
|
+
const sourceLive = await readNativeWorkerPath(mutation, change.relative, {
|
|
641
|
+
modeHint: change.after?.mode ?? null,
|
|
642
|
+
legacyMode: metadata.schema === 1 ? (change.after?.mode ?? null) : null,
|
|
643
|
+
});
|
|
644
|
+
if (
|
|
645
|
+
!sameWorkerState(sourceLive.state, change.after) ||
|
|
646
|
+
(change.after !== null &&
|
|
647
|
+
!samePortableIdentity(
|
|
648
|
+
sourceLive.observation?.identity,
|
|
649
|
+
change.finalSourceObservation?.identity,
|
|
650
|
+
))
|
|
651
|
+
) {
|
|
652
|
+
throw new Error(
|
|
653
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: final source state changed`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
let applied = 0;
|
|
660
|
+
for (const change of prepared) {
|
|
661
|
+
assertWorkerCollectionContext(cwd, metadata, collectionContext);
|
|
662
|
+
const sandboxLive = await readNativeWorkerPath(sandboxContext, change.relative, {
|
|
663
|
+
bytes: change.after?.type === "file",
|
|
664
|
+
modeHint: change.after?.mode ?? null,
|
|
665
|
+
legacyMode: metadata.schema === 1 ? (change.after?.mode ?? null) : null,
|
|
666
|
+
});
|
|
667
|
+
if (
|
|
668
|
+
!sameWorkerState(sandboxLive.state, change.after) ||
|
|
669
|
+
!samePortableIdentity(sandboxLive.observation?.identity, change.sandboxObservation?.identity)
|
|
670
|
+
) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`worker path ${workerViewPath(change.relative)} changed after collection preflight`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
const live = await readNativeWorkerPath(mutation, change.relative, {
|
|
676
|
+
modeHint: change.sourceState?.mode ?? change.after?.mode ?? null,
|
|
677
|
+
legacyMode: metadata.schema === 1 ? (change.sourceState?.mode ?? null) : null,
|
|
678
|
+
});
|
|
679
|
+
if (!samePortableIdentity(live.observation?.identity, change.sourceObservation?.identity)) {
|
|
680
|
+
throw new Error(
|
|
681
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: source identity changed after preflight`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
if (sameWorkerState(change.sourceState, change.after)) {
|
|
685
|
+
if (!sameWorkerState(live.state, change.after)) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: source changed after preflight`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
change.finalSourceObservation = live.observation;
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (!sameWorkerState(live.state, change.sourceState)) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`worker collect conflict at ${workerViewPath(change.relative)}: source changed after preflight`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
const assertContext = async () => assertWorkerCollectionContext(cwd, metadata, collectionContext);
|
|
699
|
+
if (change.after === null) {
|
|
700
|
+
await quarantineWorkerDeletion(
|
|
701
|
+
mutation,
|
|
702
|
+
change.relative,
|
|
703
|
+
metadata.workerId,
|
|
704
|
+
change.sourceState,
|
|
705
|
+
live.observation,
|
|
706
|
+
assertContext,
|
|
707
|
+
change.relative,
|
|
708
|
+
metadata.schema === 1 ? (change.sourceState?.mode ?? null) : null,
|
|
709
|
+
);
|
|
710
|
+
} else {
|
|
711
|
+
if (!change.sourcePrepared && change.sourceState !== null) {
|
|
712
|
+
await quarantineWorkerDeletion(
|
|
713
|
+
mutation,
|
|
714
|
+
change.relative,
|
|
715
|
+
metadata.workerId,
|
|
716
|
+
change.sourceState,
|
|
717
|
+
live.observation,
|
|
718
|
+
assertContext,
|
|
719
|
+
change.relative,
|
|
720
|
+
metadata.schema === 1 ? (change.sourceState?.mode ?? null) : null,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
if (change.after.type === "symlink") {
|
|
724
|
+
change.finalSourceObservation = await publishWorkerSymlink(
|
|
725
|
+
mutation,
|
|
726
|
+
change.relative,
|
|
727
|
+
change.after,
|
|
728
|
+
metadata.workerId,
|
|
729
|
+
null,
|
|
730
|
+
assertContext,
|
|
731
|
+
);
|
|
732
|
+
} else {
|
|
733
|
+
change.finalSourceObservation = await publishWorkerFile(
|
|
734
|
+
mutation,
|
|
735
|
+
change.relative,
|
|
736
|
+
sandboxLive.bytes,
|
|
737
|
+
change.after.mode,
|
|
738
|
+
null,
|
|
739
|
+
assertContext,
|
|
740
|
+
metadata.workerId,
|
|
741
|
+
metadata.schema === 1 && change.before?.type === "file" ? change.before.mode : null,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
applied += 1;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
assertWorkerCollectionContext(cwd, metadata, collectionContext);
|
|
749
|
+
await assertFinalStates();
|
|
750
|
+
const records = pendingEvidence.map(({ workerEvent, workerEventHash }) => {
|
|
751
|
+
const { ts: _ts, branch: _branch, snapshot: _snapshot, ...evidence } = workerEvent;
|
|
752
|
+
return ledgerRecord(
|
|
753
|
+
{
|
|
754
|
+
...evidence,
|
|
755
|
+
taskId: metadata.source.taskId,
|
|
756
|
+
workerId: metadata.workerId,
|
|
757
|
+
workerEventHash,
|
|
758
|
+
},
|
|
759
|
+
collectionContext.branch,
|
|
760
|
+
);
|
|
761
|
+
});
|
|
762
|
+
if (records.length > 0) {
|
|
763
|
+
await mutateLedgerWithNativeSession(mutation, records, {
|
|
764
|
+
beforeCommit: async () => {
|
|
765
|
+
assertWorkerCollectionContext(cwd, metadata, collectionContext);
|
|
766
|
+
await assertFinalStates();
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
assertWorkerCollectionContext(cwd, metadata, collectionContext);
|
|
771
|
+
await assertFinalStates();
|
|
772
|
+
console.log(
|
|
773
|
+
`stdd worker: collected ${applied} file change(s) and ${records.length} evidence event(s) ` +
|
|
774
|
+
`from ${metadata.workerId}${applied === 0 && records.length === 0 ? " (already collected)" : ""}`,
|
|
775
|
+
);
|
|
776
|
+
} finally {
|
|
777
|
+
if (mutation) await mutation.close().catch(() => {});
|
|
778
|
+
}
|
|
779
|
+
}
|