@runuai/host 0.9.167 → 0.9.169

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.
@@ -0,0 +1,1062 @@
1
+ /**
2
+ * ADR-161 §2.3 / §2.4: add a repository to a RUNNING task.
3
+ *
4
+ * The cloud drives one durable operation per add; this module is the
5
+ * runner's half: four fenced commands and the journal that makes them safe
6
+ * to repeat, lose, or interrupt.
7
+ *
8
+ * - **Prepare** clones the repository *inside the task's own runtime, as the
9
+ * task user*, into a hidden staging folder of the task's workspace
10
+ * (`/workspace/.uai/.project-staging/<op>/repo`). Staging is task-writable
11
+ * by design (the owner's decision on PR #641): nothing tells the agents
12
+ * about it before the repository is ready, and compensation discards
13
+ * whatever is in it, so an agent's edit there is lost on purpose. The clone
14
+ * is self-contained:
15
+ * its own `.git/` object store, no alternates, no Git metadata outside the
16
+ * folder, so a running Docker or Apple container (whose bind mounts cannot
17
+ * grow) and a task machine see it the same way. No Git ever runs on the
18
+ * runner's host against task data (docs/agent-notes.md, "Security
19
+ * invariants"), and the clone authenticates with exactly the credentials the
20
+ * task's own runtime already holds for its owner.
21
+ * - **Activate** first persists an *activation intent* (the operation, the
22
+ * staging and target paths, and a random activation id the staged clone
23
+ * carries in `.git/uai-activation`), then renames the clone to
24
+ * `/workspace/<slug>` with one rename(2), then persists `published`. A
25
+ * crash between any two of those steps is recovered from the disk, never
26
+ * guessed: the target holding the clone with that id (and the staging path
27
+ * gone) proves publication; the staging path holding it (and the target
28
+ * absent) proves the opposite; anything else is `uncertain` and nothing is
29
+ * deleted.
30
+ * - After publication, Activate installs the project into the task's env
31
+ * spec, then runs dependency setup as the task user, without privileges,
32
+ * best-effort. A failed step is a warning (`ready_with_warning`), never a
33
+ * rollback: package managers run arbitrary lifecycle scripts, and Uai does
34
+ * not pretend to undo them. Runtimes are never installed live: a project
35
+ * whose `.tool-versions` names one the task lacks gets a warning to restart
36
+ * the task, whose ordinary task-up materializes it (§2.4, as built).
37
+ * - **Compensate** removes only a staged clone that was provably never
38
+ * published.
39
+ * - **Status** answers the journal, with any intent resolved from the disk.
40
+ *
41
+ * Every command names the run (`runEpoch`) and the project-set revision it
42
+ * belongs to. A run the runner has not admitted is refused, and once a
43
+ * revision has begun activating or has compensated, no command for an older
44
+ * revision publishes.
45
+ */
46
+
47
+ import { randomBytes } from "node:crypto";
48
+ import {
49
+ closeSync,
50
+ constants,
51
+ fsyncSync,
52
+ lstatSync,
53
+ mkdirSync,
54
+ openSync,
55
+ readFileSync,
56
+ renameSync,
57
+ unlinkSync,
58
+ writeFileSync,
59
+ } from "node:fs";
60
+ import { dirname, join } from "node:path";
61
+
62
+ import type {
63
+ ProjectEnvSchemaObservation,
64
+ TaskCommandProject,
65
+ TaskProjectActivateInput,
66
+ TaskProjectActivateResult,
67
+ TaskProjectCompensateInput,
68
+ TaskProjectCompensateResult,
69
+ TaskProjectMilestone,
70
+ TaskProjectPrepareInput,
71
+ TaskProjectPrepareResult,
72
+ TaskProjectRefusal,
73
+ TaskProjectStatusInput,
74
+ TaskProjectStatusResult,
75
+ } from "../src/protocol";
76
+ import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
77
+ import { TaskEnvironmentRequestValidationError } from "./task-environment/types";
78
+ import type {
79
+ TaskEnvironmentExecRequest,
80
+ TaskEnvironmentExecResult,
81
+ TaskEnvironmentHandle,
82
+ } from "./task-environment/types";
83
+
84
+ /** The task user every live-project step runs as. Never root. */
85
+ export const TASK_PROJECT_USER = "node";
86
+
87
+ const SLUG = /^[a-z0-9-]{1,64}$/;
88
+ const OPERATION_ID = /^[0-9A-Za-z]{10,64}$/;
89
+ const ACTIVATION_ID = /^[0-9a-f]{32}$/;
90
+ /** Git remotes a task may clone: GitHub/GitLab-style HTTPS and SSH only.
91
+ * Never `file://`, `ext::` or a leading dash a Git option could take. */
92
+ const REPO_URL = /^(?:https:\/\/[A-Za-z0-9.-]+(?::\d+)?\/|ssh:\/\/[A-Za-z0-9._-]+@[A-Za-z0-9.-]+(?::\d+)?\/|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:)[A-Za-z0-9._~\/-]+$/;
93
+ const BRANCH = /^(?!-)(?!.*\.\.)(?!.*\/\/)[A-Za-z0-9._\/-]{1,200}$/;
94
+
95
+ export const TASK_PROJECT_CLONE_TIMEOUT_MS = 10 * 60_000;
96
+ export const TASK_PROJECT_SETUP_TIMEOUT_MS = 15 * 60_000;
97
+ const SHORT_TIMEOUT_MS = 60_000;
98
+ const OUTPUT_BYTES = 256 * 1024;
99
+
100
+ const NON_INTERACTIVE_GIT_ENV: Readonly<Record<string, string>> = Object.freeze({
101
+ GIT_TERMINAL_PROMPT: "0",
102
+ GCM_INTERACTIVE: "never",
103
+ GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // The journal
108
+ // ---------------------------------------------------------------------------
109
+
110
+ type JournalMilestone =
111
+ | "prepared"
112
+ | "activation_intent"
113
+ | "published"
114
+ | "ready"
115
+ | "ready_with_warning"
116
+ | "compensated";
117
+
118
+ export interface TaskProjectJournal {
119
+ v: 1;
120
+ operationId: string;
121
+ taskId: string;
122
+ runEpoch: number;
123
+ projectSetRevision: number;
124
+ projectId: string;
125
+ slug: string;
126
+ activationId: string;
127
+ stagingPath: string;
128
+ targetPath: string;
129
+ head: string | null;
130
+ milestone: JournalMilestone;
131
+ warning: string | null;
132
+ updatedAt: number;
133
+ }
134
+
135
+ /** The highest project-set revision that began activating or compensated:
136
+ * no older revision publishes after it. */
137
+ interface TaskProjectFence {
138
+ v: 1;
139
+ revision: number;
140
+ }
141
+
142
+ function syncDirectory(path: string): void {
143
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY);
144
+ try {
145
+ fsyncSync(fd);
146
+ } finally {
147
+ closeSync(fd);
148
+ }
149
+ }
150
+
151
+ /** tmp (O_EXCL|O_NOFOLLOW, 0600) + fsync + rename + directory fsync: a crash
152
+ * leaves either the old record or the new one, never a torn one. */
153
+ function writeDurable(path: string, value: unknown): void {
154
+ const staged = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
155
+ let fd: number | null = null;
156
+ try {
157
+ fd = openSync(
158
+ staged,
159
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
160
+ 0o600,
161
+ );
162
+ writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
163
+ fsyncSync(fd);
164
+ closeSync(fd);
165
+ fd = null;
166
+ renameSync(staged, path);
167
+ syncDirectory(dirname(path));
168
+ } catch (error) {
169
+ if (fd !== null) closeSync(fd);
170
+ try {
171
+ unlinkSync(staged);
172
+ } catch {
173
+ // The staged file never existed or is already gone.
174
+ }
175
+ throw error;
176
+ }
177
+ }
178
+
179
+ function readDurable(path: string): unknown {
180
+ let info;
181
+ try {
182
+ info = lstatSync(path);
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
185
+ throw error;
186
+ }
187
+ if (!info.isFile() || info.isSymbolicLink()) {
188
+ throw new Error(`live-project journal entry is not a regular file: ${path}`);
189
+ }
190
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
191
+ try {
192
+ return JSON.parse(readFileSync(fd, "utf8"));
193
+ } finally {
194
+ closeSync(fd);
195
+ }
196
+ }
197
+
198
+ function isJournal(value: unknown): value is TaskProjectJournal {
199
+ if (!value || typeof value !== "object") return false;
200
+ const v = value as Record<string, unknown>;
201
+ return (
202
+ v.v === 1 &&
203
+ typeof v.operationId === "string" &&
204
+ OPERATION_ID.test(v.operationId) &&
205
+ typeof v.taskId === "string" &&
206
+ Number.isSafeInteger(v.runEpoch) &&
207
+ Number.isSafeInteger(v.projectSetRevision) &&
208
+ typeof v.projectId === "string" &&
209
+ typeof v.slug === "string" &&
210
+ SLUG.test(v.slug) &&
211
+ typeof v.activationId === "string" &&
212
+ ACTIVATION_ID.test(v.activationId) &&
213
+ typeof v.stagingPath === "string" &&
214
+ typeof v.targetPath === "string" &&
215
+ (v.head === null || typeof v.head === "string") &&
216
+ typeof v.milestone === "string" &&
217
+ ["prepared", "activation_intent", "published", "ready", "ready_with_warning", "compensated"].includes(
218
+ v.milestone,
219
+ ) &&
220
+ (v.warning === null || typeof v.warning === "string")
221
+ );
222
+ }
223
+
224
+ export class TaskProjectJournalStore {
225
+ constructor(private readonly dirFor: (taskId: string) => string) {}
226
+
227
+ private dir(taskId: string): string {
228
+ const dir = join(this.dirFor(taskId), "project-ops");
229
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
230
+ const info = lstatSync(dir);
231
+ if (!info.isDirectory() || info.isSymbolicLink()) {
232
+ throw new Error("the live-project journal directory is not a private directory");
233
+ }
234
+ return dir;
235
+ }
236
+
237
+ read(taskId: string, operationId: string): TaskProjectJournal | null {
238
+ if (!OPERATION_ID.test(operationId)) throw new Error("invalid operation id");
239
+ const value = readDurable(join(this.dir(taskId), `${operationId}.json`));
240
+ if (value === null) return null;
241
+ if (!isJournal(value) || value.operationId !== operationId || value.taskId !== taskId) {
242
+ throw new Error("the live-project journal entry is malformed");
243
+ }
244
+ return value;
245
+ }
246
+
247
+ write(journal: TaskProjectJournal): void {
248
+ writeDurable(join(this.dir(journal.taskId), `${journal.operationId}.json`), {
249
+ ...journal,
250
+ updatedAt: Date.now(),
251
+ });
252
+ }
253
+
254
+ fence(taskId: string): number {
255
+ const value = readDurable(join(this.dir(taskId), "fence.json")) as TaskProjectFence | null;
256
+ if (value === null) return 0;
257
+ if (value.v !== 1 || !Number.isSafeInteger(value.revision)) {
258
+ throw new Error("the live-project fence is malformed");
259
+ }
260
+ return value.revision;
261
+ }
262
+
263
+ raiseFence(taskId: string, revision: number): void {
264
+ if (revision <= this.fence(taskId)) return;
265
+ writeDurable(join(this.dir(taskId), "fence.json"), { v: 1, revision } satisfies TaskProjectFence);
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // The scripts (literal argv; every value arrives as a positional argument)
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * $1 staging repo path, $2 target path, $3 repo URL, $4 task branch,
275
+ * $5 activation id, optional $6 the project's fallback `.tool-versions`.
276
+ * Exit 3: the target already exists. The last stdout line is the checked-out
277
+ * commit, or `empty` for a repository with no commits.
278
+ */
279
+ export const PREPARE_SCRIPT = `set -eu
280
+ staging=$1 target=$2 url=$3 branch=$4 id=$5 tool_versions=\${6:-}
281
+ if [ -e "$target" ] || [ -L "$target" ]; then
282
+ echo "uai: $target already exists" >&2
283
+ exit 3
284
+ fi
285
+ rm -rf -- "$staging"
286
+ mkdir -p -- "$(dirname -- "$staging")"
287
+ git clone --quiet --no-hardlinks --origin origin -- "$url" "$staging"
288
+ cd -- "$staging"
289
+ if git rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}" >/dev/null; then
290
+ git checkout --quiet -B "$branch" "refs/remotes/origin/$branch"
291
+ git branch --quiet --set-upstream-to="origin/$branch" "$branch"
292
+ elif git rev-parse --verify --quiet "HEAD^{commit}" >/dev/null; then
293
+ current=$(git symbolic-ref --quiet --short HEAD || true)
294
+ if [ "$current" != "$branch" ]; then
295
+ git checkout --quiet -b "$branch"
296
+ fi
297
+ else
298
+ # An empty remote: the first commit lands on the task branch, as at start.
299
+ git symbolic-ref HEAD "refs/heads/$branch"
300
+ fi
301
+ # The project's fallback runtimes, only when the repository ships none
302
+ # (task-up.sh's seeding, so the setup check sees what a start would).
303
+ if [ -n "$tool_versions" ] && [ ! -e .tool-versions ] && [ ! -L .tool-versions ]; then
304
+ printf '%s\\n' "$tool_versions" > .tool-versions
305
+ fi
306
+ printf '%s\\n' "$id" > .git/uai-activation
307
+ git rev-parse --verify --quiet "HEAD^{commit}" || echo empty
308
+ `;
309
+
310
+ /**
311
+ * $1 staging, $2 target, $3 activation id. One rename(2). Exit 0: renamed
312
+ * (or already renamed by this activation). Exit 3: the target is someone
313
+ * else's. Exit 4: the staged clone is not this activation's.
314
+ */
315
+ export const RENAME_SCRIPT = `set -eu
316
+ staging=$1 target=$2 id=$3
317
+ marker() { cat -- "$1/.git/uai-activation" 2>/dev/null || true; }
318
+ if [ -e "$target" ] || [ -L "$target" ]; then
319
+ if [ ! -L "$target" ] && [ "$(marker "$target")" = "$id" ] && [ ! -e "$staging" ]; then
320
+ exit 0
321
+ fi
322
+ exit 3
323
+ fi
324
+ if [ -L "$staging" ] || [ ! -d "$staging" ] || [ "$(marker "$staging")" != "$id" ]; then
325
+ exit 4
326
+ fi
327
+ mv -T -- "$staging" "$target"
328
+ `;
329
+
330
+ /** $1 staging, $2 target, $3 activation id. Prints exactly one verdict. */
331
+ export const RESOLVE_SCRIPT = `set -u
332
+ staging=$1 target=$2 id=$3
333
+ marker() { cat -- "$1/.git/uai-activation" 2>/dev/null || true; }
334
+ target_ours=0 staging_ours=0
335
+ if [ -d "$target" ] && [ ! -L "$target" ] && [ "$(marker "$target")" = "$id" ]; then target_ours=1; fi
336
+ if [ -d "$staging" ] && [ ! -L "$staging" ] && [ "$(marker "$staging")" = "$id" ]; then staging_ours=1; fi
337
+ if [ "$target_ours" = 1 ] && [ ! -e "$staging" ] && [ ! -L "$staging" ]; then echo published
338
+ elif [ "$staging_ours" = 1 ] && [ ! -e "$target" ] && [ ! -L "$target" ]; then echo not_published
339
+ else echo uncertain
340
+ fi
341
+ `;
342
+
343
+ /** $1 repository, then git's own arguments. */
344
+ export const GIT_IN_SCRIPT = `set -eu
345
+ repo=$1
346
+ shift
347
+ exec git -C "$repo" "$@"
348
+ `;
349
+
350
+ /** $1 staging op directory. Removes only what Prepare made. */
351
+ export const COMPENSATE_SCRIPT = `set -eu
352
+ rm -rf -- "$1"
353
+ `;
354
+
355
+ /** $1 target. Prints the Git top level as the runtime sees it. */
356
+ export const TOPLEVEL_SCRIPT = `set -eu
357
+ cd -- "$1"
358
+ git rev-parse --show-toplevel
359
+ `;
360
+
361
+ /**
362
+ * Run in the published folder as the task user, with the task's project env:
363
+ * the same per-folder heuristics as uai-init's install_folder_deps, but each
364
+ * failure is reported instead of swallowed. Runtimes are only checked.
365
+ *
366
+ * The result travels on this script's stdout as `uai-setup-<nonce> <kind>
367
+ * <text>` lines, and nothing else writes there: every installer runs with
368
+ * stdout sent to stderr and stdin from /dev/null. The nonce arrives on this
369
+ * script's stdin, never in argv or the environment, so a repository's
370
+ * lifecycle scripts can neither read it nor forge a result line (PR #641
371
+ * review).
372
+ */
373
+ export const SETUP_SCRIPT = `set -u
374
+ IFS= read -r nonce || nonce=""
375
+ case "$nonce" in *[!0-9a-f]*|"") echo "uai: setup needs its nonce" >&2; exit 64 ;; esac
376
+ mark() { printf 'uai-setup-%s %s %s\\n' "$nonce" "$1" "$2"; }
377
+ quiet() { "$@" </dev/null 1>&2; }
378
+ # The effective requirements: this folder's .tool-versions (the repository's,
379
+ # or the project's fallback Prepare seeded, as at start), parsed and checked
380
+ # with the startup materializer's rules (uai-materialize-runtimes,
381
+ # validate_manifest/validate_version). Every selector is either proven
382
+ # usable or reported: a probe that can't run, or one it skips, never reads as
383
+ # installed (asdf 0.14 prints "not installed" on stderr).
384
+ check_runtime() {
385
+ tool=$1 version=$2
386
+ case "$version" in
387
+ system) return 0 ;;
388
+ path:*)
389
+ suffix=\${version#path:}
390
+ case "$suffix" in
391
+ ""|/*|"~"*) mark invalid-runtime "$tool $version (path: must name a directory inside the repository)"; return 0 ;;
392
+ esac
393
+ resolved=$(realpath -e -- "$suffix" 2>/dev/null) || { mark invalid-runtime "$tool $version (path: does not resolve)"; return 0; }
394
+ here=$(realpath -e -- . 2>/dev/null) || here=$PWD
395
+ case "$resolved" in
396
+ "$here"/*) [ -d "$resolved" ] || mark invalid-runtime "$tool $version (path: is not a directory)" ;;
397
+ *) mark invalid-runtime "$tool $version (path: escapes the repository)" ;;
398
+ esac
399
+ return 0
400
+ ;;
401
+ ref:*)
402
+ suffix=\${version#ref:}
403
+ case "$suffix" in
404
+ ""|*/*) mark invalid-runtime "$tool $version (ref: needs one path segment)"; return 0 ;;
405
+ esac
406
+ [[ "$suffix" =~ ^[[:alnum:]][[:alnum:].+_@-]*$ ]] || { mark invalid-runtime "$tool $version (malformed ref:)"; return 0; }
407
+ ;;
408
+ ref-*|*/*) mark invalid-runtime "$tool $version (a start would refuse this version)"; return 0 ;;
409
+ *)
410
+ [[ "$version" =~ ^[[:alnum:]][[:alnum:].+_@-]*$ ]] || { mark invalid-runtime "$tool $version (malformed version)"; return 0; }
411
+ ;;
412
+ esac
413
+ case "/$version/" in *"/../"*|*"/./"*|*"//"*) mark invalid-runtime "$tool $version (unsafe version path)"; return 0 ;; esac
414
+ if ! command -v asdf >/dev/null 2>&1; then
415
+ mark missing-runtime "$tool $version (no asdf to check it)"
416
+ elif ! asdf where "$tool" "$version" </dev/null >/dev/null 2>&1; then
417
+ mark missing-runtime "$tool $version"
418
+ fi
419
+ }
420
+ if [ -L .tool-versions ]; then
421
+ mark invalid-runtime ".tool-versions (a symbolic link; a start would refuse it)"
422
+ elif [ -f .tool-versions ]; then
423
+ if LC_ALL=C grep -q '[[:cntrl:]]' .tool-versions; then
424
+ mark invalid-runtime ".tool-versions (control characters; a start would refuse it)"
425
+ else
426
+ while IFS= read -r line || [ -n "$line" ]; do
427
+ line=\${line%%#*}
428
+ read -r -a fields <<< "$line" || true
429
+ [ "\${#fields[@]}" -gt 0 ] || continue
430
+ tool=\${fields[0]}
431
+ if [ "\${#fields[@]}" -lt 2 ] || [[ ! "$tool" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
432
+ mark invalid-runtime "$line (a start would refuse this line)"
433
+ continue
434
+ fi
435
+ case "$tool" in
436
+ nodejs|python|golang|ruby|rust)
437
+ for version in "\${fields[@]:1}"; do check_runtime "$tool" "$version"; done
438
+ ;;
439
+ pnpm|yarn)
440
+ # Corepack's, as at start: one exact pinned version.
441
+ if [ "\${#fields[@]}" -ne 2 ] || [[ ! "\${fields[1]}" =~ ^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
442
+ mark invalid-runtime "$tool \${fields[*]:1} (must pin one exact version)"
443
+ fi
444
+ ;;
445
+ *) mark invalid-runtime "$tool (not a runtime a task can install)" ;;
446
+ esac
447
+ done < .tool-versions
448
+ fi
449
+ fi
450
+ if [ -f package.json ]; then
451
+ pm=$(jq -r '.packageManager // ""' package.json 2>/dev/null </dev/null | sed 's/@.*//' || true)
452
+ if [ -z "$pm" ]; then
453
+ if [ -f pnpm-lock.yaml ]; then pm=pnpm
454
+ elif [ -f yarn.lock ]; then pm=yarn
455
+ elif [ -f bun.lockb ] || [ -f bun.lock ]; then pm=bun
456
+ else pm=npm
457
+ fi
458
+ fi
459
+ case "$pm" in
460
+ pnpm) quiet pnpm install || mark failed-step "pnpm install" ;;
461
+ yarn) quiet yarn install || mark failed-step "yarn install" ;;
462
+ bun) quiet bun install || mark failed-step "bun install" ;;
463
+ *) quiet npm install --no-audit --no-fund || mark failed-step "npm install" ;;
464
+ esac
465
+ fi
466
+ if [ -f pyproject.toml ]; then
467
+ quiet uv sync || mark failed-step "uv sync"
468
+ elif [ -f requirements.txt ]; then
469
+ { quiet uv venv && quiet uv pip install -r requirements.txt; } || mark failed-step "uv pip install"
470
+ fi
471
+ if [ -f Cargo.toml ]; then quiet cargo build || mark failed-step "cargo build"; fi
472
+ if [ -f go.mod ]; then quiet go mod download || mark failed-step "go mod download"; fi
473
+ exit 0
474
+ `;
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // The commands
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /** A warning reaches chat and every agent's note: one line, bounded. */
481
+ export const TASK_PROJECT_WARNING_MAX = 4096;
482
+
483
+ /** Control characters (newlines included) and whitespace runs become one space. */
484
+ export function oneLine(value: string): string {
485
+ return value.replace(/[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").trim();
486
+ }
487
+
488
+ export function boundWarning(value: string): string {
489
+ const line = oneLine(value);
490
+ return line.length > TASK_PROJECT_WARNING_MAX ? `${line.slice(0, TASK_PROJECT_WARNING_MAX - 1)}…` : line;
491
+ }
492
+
493
+ /** Only lines carrying this run's nonce count; each item is one bounded
494
+ * line, and at most 20 of each kind are kept. */
495
+ export function parseSetupResult(
496
+ stdout: string,
497
+ nonce: string,
498
+ ): { missing: string[]; invalid: string[]; failed: string[] } {
499
+ const missing: string[] = [];
500
+ const invalid: string[] = [];
501
+ const failed: string[] = [];
502
+ const prefix = `uai-setup-${nonce} `;
503
+ for (const raw of stdout.split("\n")) {
504
+ if (!raw.startsWith(prefix)) continue;
505
+ const rest = raw.slice(prefix.length);
506
+ const space = rest.indexOf(" ");
507
+ if (space < 0) continue;
508
+ const kind = rest.slice(0, space);
509
+ const item = oneLine(rest.slice(space + 1)).slice(0, 200);
510
+ if (!item) continue;
511
+ if (kind === "missing-runtime" && missing.length < 20) missing.push(item);
512
+ if (kind === "invalid-runtime" && invalid.length < 20) invalid.push(item);
513
+ if (kind === "failed-step" && failed.length < 20) failed.push(item);
514
+ }
515
+ return { missing, invalid, failed };
516
+ }
517
+
518
+ export type TaskProjectEnvironment = Pick<TaskEnvironmentHandle, "exec" | "descriptor">;
519
+
520
+ export interface TaskProjectOpsDeps {
521
+ journal: TaskProjectJournalStore;
522
+ /** The task's runtime while its current run is live; null otherwise. */
523
+ environment(taskId: string): Promise<TaskProjectEnvironment | null>;
524
+ /** Whether `runEpoch` is the task's admitted, open run right now. */
525
+ runIsCurrent(taskId: string, runEpoch: number): boolean;
526
+ /** Runs `fn` in the task's lifecycle slot (short critical sections only). */
527
+ inLifecycleSlot<T>(taskId: string, fn: () => Promise<T>): Promise<T>;
528
+ credentials(): "cloud" | "own";
529
+ /** The grant the run holds now (cloud credentials), else null. */
530
+ installedGrant(
531
+ taskId: string,
532
+ runEpoch: number,
533
+ ): { runEpoch: number; manifestRevision: number; manifestDigest: string } | null;
534
+ /** Whether the task's env serves `projectId` for this run now. */
535
+ envServes(taskId: string, runEpoch: number, projectId: string): boolean;
536
+ /** Every project the run's env serves now (cloud credentials), else null. */
537
+ servedProjectIds(taskId: string, runEpoch: number): string[] | null;
538
+ /** Whether the running environment can take a project live: it has a
539
+ * persisted env spec and a live env gateway to extend. */
540
+ liveProjectsSupported(taskId: string): boolean;
541
+ /** Reads and stores the `.env.schema` observation of a fresh clone (git
542
+ * run in it); the stored one when there is one. */
543
+ captureEnvSchema(
544
+ taskId: string,
545
+ projectId: string,
546
+ git: (args: string[]) => Promise<Buffer | null>,
547
+ ): Promise<ProjectEnvSchemaObservation>;
548
+ storedEnvSchema(taskId: string, projectId: string): ProjectEnvSchemaObservation | null;
549
+ /** Adds the project (and its preview URL literals) to the task's persisted
550
+ * env spec and always re-points the live resolver at it. Idempotent;
551
+ * throws when the environment can't take it. */
552
+ installProjectEnv(
553
+ taskId: string,
554
+ runEpoch: number,
555
+ project: TaskCommandProject,
556
+ previewEnv: Record<string, string>,
557
+ ): void;
558
+ /** Test seam: called right after the rename, before `published` is
559
+ * journaled. Throwing simulates a crash at the worst moment. */
560
+ afterRename?: () => void;
561
+ now?: () => number;
562
+ }
563
+
564
+ function refused<T extends { outcome: string }>(
565
+ reason: TaskProjectRefusal | "uncertain",
566
+ message: string,
567
+ ): T {
568
+ return { outcome: "refused", reason, message } as unknown as T;
569
+ }
570
+
571
+ function text(bytes: Uint8Array): string {
572
+ return Buffer.from(bytes).toString("utf8");
573
+ }
574
+
575
+ function tail(value: string, max = 600): string {
576
+ const trimmed = value.trim();
577
+ return trimmed.length > max ? `…${trimmed.slice(trimmed.length - max)}` : trimmed;
578
+ }
579
+
580
+ function scriptRequest(
581
+ script: string,
582
+ name: string,
583
+ args: readonly string[],
584
+ options: {
585
+ cwd: string;
586
+ timeoutMs: number;
587
+ profile?: TaskEnvironmentExecRequest["projectEnvProfile"];
588
+ },
589
+ ): TaskEnvironmentExecRequest {
590
+ return {
591
+ argv: [
592
+ "/usr/bin/timeout",
593
+ "--signal=KILL",
594
+ String(Math.ceil(options.timeoutMs / 1000)),
595
+ "/bin/bash",
596
+ "-c",
597
+ script,
598
+ name,
599
+ ...args,
600
+ ],
601
+ cwd: options.cwd,
602
+ user: TASK_PROJECT_USER,
603
+ env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
604
+ projectEnvProfile: options.profile ?? "maintenance",
605
+ timeoutMs: options.timeoutMs + 15_000,
606
+ maxOutputBytes: OUTPUT_BYTES,
607
+ };
608
+ }
609
+
610
+ function paths(workspacePath: string, operationId: string, slug: string) {
611
+ const root = workspacePath.replace(/\/+$/, "");
612
+ const opDir = `${root}/.uai/.project-staging/${operationId}`;
613
+ return { opDir, stagingPath: `${opDir}/repo`, targetPath: `${root}/${slug}` };
614
+ }
615
+
616
+ const ACCESS_DENIED = [
617
+ /authentication failed/i,
618
+ /permission denied \(publickey/i,
619
+ /repository not found/i,
620
+ /could not read (username|password)/i,
621
+ /terminal prompts disabled/i,
622
+ /access denied/i,
623
+ /\b403\b/,
624
+ /host key verification failed/i,
625
+ ];
626
+
627
+ export function classifyCloneFailure(result: TaskEnvironmentExecResult): {
628
+ reason: TaskProjectRefusal;
629
+ message: string;
630
+ } {
631
+ const stderr = text(result.stderr);
632
+ if (result.exitCode === 3) {
633
+ return { reason: "workspace_path_taken", message: tail(stderr) || "the target folder already exists" };
634
+ }
635
+ if (ACCESS_DENIED.some((pattern) => pattern.test(stderr))) {
636
+ return {
637
+ reason: "repo_access_denied",
638
+ message:
639
+ `the task's credentials cannot read this repository: ${tail(stderr, 300)}. ` +
640
+ "Fix the owner's GitHub App/user access or SSH key, then retry.",
641
+ };
642
+ }
643
+ return {
644
+ reason: "clone_failed",
645
+ message: tail(stderr) || `the clone exited ${result.exitCode ?? result.signal ?? "?"}`,
646
+ };
647
+ }
648
+
649
+ export class TaskProjectOps {
650
+ /** One live-project command at a time per task. */
651
+ private readonly tails = new Map<string, Promise<unknown>>();
652
+
653
+ constructor(private readonly deps: TaskProjectOpsDeps) {}
654
+
655
+ private serialized<T>(taskId: string, fn: () => Promise<T>): Promise<T> {
656
+ const previous = this.tails.get(taskId) ?? Promise.resolve();
657
+ const run = previous.catch(() => undefined).then(fn);
658
+ const settled = run.catch(() => undefined);
659
+ this.tails.set(taskId, settled);
660
+ void settled.then(() => {
661
+ if (this.tails.get(taskId) === settled) this.tails.delete(taskId);
662
+ });
663
+ return run;
664
+ }
665
+
666
+ private fenceFails(input: { taskId: string; runEpoch: number; projectSetRevision?: number }):
667
+ | { reason: TaskProjectRefusal; message: string }
668
+ | null {
669
+ if (!this.deps.runIsCurrent(input.taskId, input.runEpoch)) {
670
+ return {
671
+ reason: "not_running",
672
+ message: `run ${input.runEpoch} is not this task's admitted, open run`,
673
+ };
674
+ }
675
+ if (
676
+ input.projectSetRevision !== undefined &&
677
+ input.projectSetRevision < this.deps.journal.fence(input.taskId)
678
+ ) {
679
+ return {
680
+ reason: "stale",
681
+ message: `project-set revision ${input.projectSetRevision} is older than one this runner already activated or compensated`,
682
+ };
683
+ }
684
+ return null;
685
+ }
686
+
687
+ prepare(input: TaskProjectPrepareInput): Promise<TaskProjectPrepareResult> {
688
+ return this.serialized<TaskProjectPrepareResult>(input.taskId, async () => {
689
+ if (!OPERATION_ID.test(input.operationId)) throw new TaskEnvironmentRequestValidationError("invalid operation id");
690
+ if (!SLUG.test(input.project.slug)) throw new TaskEnvironmentRequestValidationError("invalid project slug");
691
+ if (!REPO_URL.test(input.project.repoUrl)) {
692
+ throw new TaskEnvironmentRequestValidationError("the repository URL is not an HTTPS or SSH Git remote");
693
+ }
694
+ if (!BRANCH.test(input.branch)) throw new TaskEnvironmentRequestValidationError("invalid task branch");
695
+ const fenced = this.fenceFails(input);
696
+ if (fenced) return refused<TaskProjectPrepareResult>(fenced.reason, fenced.message);
697
+
698
+ const existing = this.deps.journal.read(input.taskId, input.operationId);
699
+ if (existing) {
700
+ if (existing.milestone === "compensated") {
701
+ return refused<TaskProjectPrepareResult>("compensated", "this operation was compensated");
702
+ }
703
+ return {
704
+ outcome: "prepared",
705
+ head: existing.head,
706
+ envSchema: this.deps.storedEnvSchema(input.taskId, existing.projectId),
707
+ };
708
+ }
709
+ if (!this.deps.liveProjectsSupported(input.taskId)) {
710
+ return refused<TaskProjectPrepareResult>(
711
+ "unsupported_environment",
712
+ "this task's environment fixes its env at start, so it can't take a repository while it runs",
713
+ );
714
+ }
715
+
716
+ const environment = await this.deps.environment(input.taskId);
717
+ if (!environment) {
718
+ return refused<TaskProjectPrepareResult>("not_running", "the task's runtime is not available");
719
+ }
720
+ const where = paths(environment.descriptor.workspacePath, input.operationId, input.project.slug);
721
+ const activationId = randomBytes(16).toString("hex");
722
+ const result = await environment.exec(
723
+ scriptRequest(
724
+ PREPARE_SCRIPT,
725
+ "uai-project-prepare",
726
+ [
727
+ where.stagingPath,
728
+ where.targetPath,
729
+ input.project.repoUrl,
730
+ input.branch,
731
+ activationId,
732
+ // The provider rejects empty argv elements. The script already
733
+ // defaults an absent final argument to no fallback (${6:-}).
734
+ ...(input.project.toolVersions ? [input.project.toolVersions] : []),
735
+ ],
736
+ { cwd: environment.descriptor.workspacePath, timeoutMs: TASK_PROJECT_CLONE_TIMEOUT_MS },
737
+ ),
738
+ );
739
+ if (result.exitCode !== 0) {
740
+ const failure = classifyCloneFailure(result);
741
+ // Leave nothing half-cloned behind; the folder is ours alone.
742
+ await environment
743
+ .exec(
744
+ scriptRequest(COMPENSATE_SCRIPT, "uai-project-compensate", [where.opDir], {
745
+ cwd: environment.descriptor.workspacePath,
746
+ timeoutMs: SHORT_TIMEOUT_MS,
747
+ }),
748
+ )
749
+ .catch(() => undefined);
750
+ return refused<TaskProjectPrepareResult>(failure.reason, failure.message);
751
+ }
752
+ // The run may have ended during a long clone.
753
+ const after = this.fenceFails(input);
754
+ if (after) return refused<TaskProjectPrepareResult>(after.reason, after.message);
755
+ const lines = text(result.stdout).trim().split("\n");
756
+ const last = lines[lines.length - 1]?.trim() ?? "";
757
+ const head = /^[0-9a-f]{40,64}$/.test(last) ? last : null;
758
+ // ADR-153 §4: the schema observation is read from the fresh clone and
759
+ // stored before the project reaches any env consumer (Activate).
760
+ const envSchema = await this.deps.captureEnvSchema(input.taskId, input.project.id, async (args) => {
761
+ const ran = await environment.exec(
762
+ scriptRequest(GIT_IN_SCRIPT, "uai-project-git", [where.stagingPath, ...args], {
763
+ cwd: environment.descriptor.workspacePath,
764
+ timeoutMs: SHORT_TIMEOUT_MS,
765
+ }),
766
+ );
767
+ return ran.exitCode === 0 ? Buffer.from(ran.stdout) : null;
768
+ });
769
+ this.deps.journal.write({
770
+ v: 1,
771
+ operationId: input.operationId,
772
+ taskId: input.taskId,
773
+ runEpoch: input.runEpoch,
774
+ projectSetRevision: input.projectSetRevision,
775
+ projectId: input.project.id,
776
+ slug: input.project.slug,
777
+ activationId,
778
+ stagingPath: where.stagingPath,
779
+ targetPath: where.targetPath,
780
+ head,
781
+ milestone: "prepared",
782
+ warning: null,
783
+ updatedAt: Date.now(),
784
+ });
785
+ return { outcome: "prepared", head, envSchema };
786
+ }).catch((error: unknown) => {
787
+ if (error instanceof TaskEnvironmentRequestValidationError) {
788
+ return refused<TaskProjectPrepareResult>("invalid_request", error.message);
789
+ }
790
+ // Provider availability, rebuilds and journal I/O remain INTERNAL so
791
+ // the cloud can retry them; never classify an error by its message.
792
+ throw error;
793
+ });
794
+ }
795
+
796
+ /** Reads the disk for an intent with no `published`. Journals `published`
797
+ * when proven. Call inside the lifecycle slot. */
798
+ private async resolveIntent(
799
+ journal: TaskProjectJournal,
800
+ environment: TaskProjectEnvironment,
801
+ ): Promise<"published" | "not_published" | "uncertain"> {
802
+ const result = await environment.exec(
803
+ scriptRequest(
804
+ RESOLVE_SCRIPT,
805
+ "uai-project-resolve",
806
+ [journal.stagingPath, journal.targetPath, journal.activationId],
807
+ { cwd: environment.descriptor.workspacePath, timeoutMs: SHORT_TIMEOUT_MS },
808
+ ),
809
+ );
810
+ if (result.exitCode !== 0) return "uncertain";
811
+ const verdict = text(result.stdout).trim();
812
+ if (verdict === "published") {
813
+ this.deps.journal.write({ ...journal, milestone: "published" });
814
+ return "published";
815
+ }
816
+ return verdict === "not_published" ? "not_published" : "uncertain";
817
+ }
818
+
819
+ activate(input: TaskProjectActivateInput): Promise<TaskProjectActivateResult> {
820
+ return this.serialized(input.taskId, async () => {
821
+ const fenced = this.fenceFails(input);
822
+ if (fenced) return refused<TaskProjectActivateResult>(fenced.reason, fenced.message);
823
+ let journal = this.deps.journal.read(input.taskId, input.operationId);
824
+ if (!journal) return refused<TaskProjectActivateResult>("not_prepared", "this operation was never prepared here");
825
+ if (journal.milestone === "compensated") {
826
+ return refused<TaskProjectActivateResult>("compensated", "this operation was compensated");
827
+ }
828
+ if (journal.milestone === "ready" || journal.milestone === "ready_with_warning") {
829
+ return { outcome: journal.milestone, warning: journal.warning };
830
+ }
831
+ if (journal.projectId !== input.project.id || journal.slug !== input.project.slug) {
832
+ throw new Error("the activation names a different project than its preparation");
833
+ }
834
+ const environment = await this.deps.environment(input.taskId);
835
+ if (!environment) return refused<TaskProjectActivateResult>("not_running", "the task's runtime is not available");
836
+
837
+ // Intent, rename and `published` happen in the lifecycle slot, so a
838
+ // stop or teardown never lands between the run check and the rename.
839
+ const publication = await this.deps.inLifecycleSlot(input.taskId, async () => {
840
+ const inSlot = this.fenceFails(input);
841
+ if (inSlot) return { refused: inSlot } as const;
842
+ let current = this.deps.journal.read(input.taskId, input.operationId)!;
843
+ if (current.milestone === "activation_intent") {
844
+ const verdict = await this.resolveIntent(current, environment);
845
+ if (verdict === "uncertain") return { uncertain: true } as const;
846
+ current = this.deps.journal.read(input.taskId, input.operationId)!;
847
+ }
848
+ if (current.milestone === "prepared" || current.milestone === "activation_intent") {
849
+ if (input.grant !== null) {
850
+ const installed = this.deps.installedGrant(input.taskId, input.runEpoch);
851
+ if (
852
+ !installed ||
853
+ installed.manifestRevision !== input.grant.manifestRevision ||
854
+ installed.manifestDigest !== input.grant.manifestDigest
855
+ ) {
856
+ return {
857
+ refused: {
858
+ reason: "grant_not_installed" as const,
859
+ message: `this run holds ${installed ? `grant ${installed.manifestRevision}` : "no grant"}, not ${input.grant.manifestRevision}`,
860
+ },
861
+ } as const;
862
+ }
863
+ }
864
+ if (!this.deps.envServes(input.taskId, input.runEpoch, input.project.id)) {
865
+ return {
866
+ refused: {
867
+ reason: "grant_not_installed" as const,
868
+ message: "the task's env does not serve this project yet",
869
+ },
870
+ } as const;
871
+ }
872
+ // No older revision may publish once this one has begun.
873
+ this.deps.journal.raiseFence(input.taskId, input.projectSetRevision);
874
+ if (current.milestone === "prepared") {
875
+ current = { ...current, milestone: "activation_intent" };
876
+ this.deps.journal.write(current);
877
+ }
878
+ const renamed = await environment.exec(
879
+ scriptRequest(
880
+ RENAME_SCRIPT,
881
+ "uai-project-publish",
882
+ [current.stagingPath, current.targetPath, current.activationId],
883
+ { cwd: environment.descriptor.workspacePath, timeoutMs: SHORT_TIMEOUT_MS },
884
+ ),
885
+ );
886
+ if (renamed.exitCode !== 0) {
887
+ const verdict = await this.resolveIntent(current, environment);
888
+ if (verdict === "published") {
889
+ current = this.deps.journal.read(input.taskId, input.operationId)!;
890
+ } else if (verdict === "not_published" && renamed.exitCode === 3) {
891
+ return {
892
+ refused: {
893
+ reason: "workspace_path_taken" as const,
894
+ message: `${current.targetPath} appeared before the rename`,
895
+ },
896
+ } as const;
897
+ } else {
898
+ return { uncertain: true } as const;
899
+ }
900
+ } else {
901
+ this.deps.afterRename?.();
902
+ current = { ...current, milestone: "published" };
903
+ this.deps.journal.write(current);
904
+ }
905
+ }
906
+ return { journal: current } as const;
907
+ });
908
+ if ("refused" in publication && publication.refused) {
909
+ return refused<TaskProjectActivateResult>(publication.refused.reason, publication.refused.message);
910
+ }
911
+ if ("uncertain" in publication) {
912
+ return {
913
+ outcome: "uncertain",
914
+ message: "the activation intent is recorded but the disk proves neither outcome; nothing was removed",
915
+ };
916
+ }
917
+ journal = publication.journal;
918
+
919
+ // Published. Everything below repairs forward and never deletes. The
920
+ // Git root and the env install are REQUIRED: a failure leaves the
921
+ // journal at `published` and answers `incomplete`, so a repeated
922
+ // Activate repairs them. Only dependency setup ends in a warning.
923
+ const toplevel = await environment.exec(
924
+ scriptRequest(TOPLEVEL_SCRIPT, "uai-project-toplevel", [journal.targetPath], {
925
+ cwd: environment.descriptor.workspacePath,
926
+ timeoutMs: SHORT_TIMEOUT_MS,
927
+ }),
928
+ );
929
+ if (toplevel.exitCode !== 0 || text(toplevel.stdout).trim() !== journal.targetPath) {
930
+ return refused<TaskProjectActivateResult>(
931
+ "incomplete",
932
+ `Git does not see ${journal.targetPath} as its own repository root yet`,
933
+ );
934
+ }
935
+ try {
936
+ this.deps.installProjectEnv(input.taskId, input.runEpoch, input.project, input.previewEnv);
937
+ } catch (error) {
938
+ return refused<TaskProjectActivateResult>(
939
+ "incomplete",
940
+ `the project's env is not installed for this run yet: ${error instanceof Error ? error.message : String(error)}`,
941
+ );
942
+ }
943
+ const warnings: string[] = [];
944
+ const nonce = randomBytes(16).toString("hex");
945
+ const setup = await environment
946
+ .exec({
947
+ ...scriptRequest(SETUP_SCRIPT, "uai-project-setup", [], {
948
+ cwd: journal.targetPath,
949
+ timeoutMs: TASK_PROJECT_SETUP_TIMEOUT_MS,
950
+ profile: "task",
951
+ }),
952
+ stdin: new TextEncoder().encode(`${nonce}\n`),
953
+ })
954
+ .catch((error: unknown) => ({ error }));
955
+ if ("error" in setup) {
956
+ warnings.push(`dependency setup could not run: ${String((setup as { error: unknown }).error)}`);
957
+ } else {
958
+ const { missing, invalid, failed } = parseSetupResult(text(setup.stdout), nonce);
959
+ if (invalid.length > 0) {
960
+ warnings.push(
961
+ `the repository's .tool-versions has entries a task start would refuse (${invalid.join(", ")}); fix them before the task restarts`,
962
+ );
963
+ }
964
+ if (missing.length > 0) {
965
+ warnings.push(
966
+ `runtimes this repository asks for are not installed in this task (${missing.join(", ")}). ` +
967
+ "Uai doesn't install runtimes or extend the task's runtime allowlist while it runs; restart the task to install them",
968
+ );
969
+ }
970
+ if (failed.length > 0) warnings.push(`dependency setup failed: ${failed.join(", ")}`);
971
+ if (setup.exitCode !== 0 && failed.length === 0) {
972
+ warnings.push(`dependency setup exited ${setup.exitCode ?? setup.signal ?? "?"}`);
973
+ }
974
+ }
975
+ const warning = warnings.length > 0 ? boundWarning(warnings.join("; ")) : null;
976
+ const milestone = warning === null ? "ready" : "ready_with_warning";
977
+ this.deps.journal.write({ ...journal, milestone, warning });
978
+ return { outcome: milestone, warning };
979
+ });
980
+ }
981
+
982
+ /**
983
+ * Discards a staged clone that was provably never published — and with
984
+ * it anything an agent wrote there (staging is task-writable by design).
985
+ * Fenced like publication: only for this task's admitted run, only for the
986
+ * operation's own run and revision, and decided inside the lifecycle slot.
987
+ */
988
+ compensate(input: TaskProjectCompensateInput): Promise<TaskProjectCompensateResult> {
989
+ return this.serialized(input.taskId, async () => {
990
+ const environment = await this.deps.environment(input.taskId);
991
+ if (!environment) {
992
+ return refused<TaskProjectCompensateResult>("not_running", "the task's runtime is not available");
993
+ }
994
+ return this.deps.inLifecycleSlot(input.taskId, async () => {
995
+ if (!this.deps.runIsCurrent(input.taskId, input.runEpoch)) {
996
+ return refused<TaskProjectCompensateResult>(
997
+ "not_running",
998
+ `run ${input.runEpoch} is not this task's admitted, open run`,
999
+ );
1000
+ }
1001
+ const current = this.deps.journal.read(input.taskId, input.operationId);
1002
+ // The fence first, even for a repeat: a cached `compensated` answers
1003
+ // only the run and revision it was for (PR #641 re-review).
1004
+ if (current && (current.runEpoch !== input.runEpoch || current.projectSetRevision !== input.projectSetRevision)) {
1005
+ return refused<TaskProjectCompensateResult>("stale", "this compensation names another run or revision than the operation");
1006
+ }
1007
+ if (current?.milestone === "compensated") return { outcome: "compensated" } as const;
1008
+ if (current && ["published", "ready", "ready_with_warning"].includes(current.milestone)) {
1009
+ return refused<TaskProjectCompensateResult>("published", "the repository was published; recovery rolls forward");
1010
+ }
1011
+ if (current?.milestone === "activation_intent") {
1012
+ const verdict = await this.resolveIntent(current, environment);
1013
+ if (verdict === "published") {
1014
+ return refused<TaskProjectCompensateResult>("published", "the repository was published; recovery rolls forward");
1015
+ }
1016
+ if (verdict === "uncertain") {
1017
+ return refused<TaskProjectCompensateResult>("uncertain", "the disk proves neither outcome; nothing was removed");
1018
+ }
1019
+ }
1020
+ const where = paths(environment.descriptor.workspacePath, input.operationId, current?.slug ?? "x");
1021
+ const removed = await environment.exec(
1022
+ scriptRequest(COMPENSATE_SCRIPT, "uai-project-compensate", [where.opDir], {
1023
+ cwd: environment.descriptor.workspacePath,
1024
+ timeoutMs: SHORT_TIMEOUT_MS,
1025
+ }),
1026
+ );
1027
+ if (removed.exitCode !== 0) throw new Error(`could not remove the staged clone: ${tail(text(removed.stderr))}`);
1028
+ this.deps.journal.raiseFence(input.taskId, input.projectSetRevision);
1029
+ if (current) this.deps.journal.write({ ...current, milestone: "compensated" });
1030
+ return { outcome: "compensated" } as const;
1031
+ });
1032
+ });
1033
+ }
1034
+
1035
+ status(input: TaskProjectStatusInput): Promise<TaskProjectStatusResult> {
1036
+ return this.serialized(input.taskId, async () => {
1037
+ const credentials = this.deps.credentials();
1038
+ const live = this.deps.runIsCurrent(input.taskId, input.runEpoch);
1039
+ const base = {
1040
+ installedGrant: live && credentials === "cloud" ? this.deps.installedGrant(input.taskId, input.runEpoch) : null,
1041
+ envServes: live && this.deps.envServes(input.taskId, input.runEpoch, input.projectId),
1042
+ servedProjectIds: live && credentials === "cloud" ? this.deps.servedProjectIds(input.taskId, input.runEpoch) : null,
1043
+ credentials,
1044
+ };
1045
+ let journal = this.deps.journal.read(input.taskId, input.operationId);
1046
+ if (!journal) return { milestone: "none", warning: null, ...base };
1047
+ let milestone: TaskProjectMilestone;
1048
+ if (journal.milestone === "activation_intent") {
1049
+ const environment = await this.deps.environment(input.taskId);
1050
+ if (!environment) throw new Error("the task's runtime is not available to resolve its activation");
1051
+ milestone = await this.deps.inLifecycleSlot(input.taskId, async () => {
1052
+ journal = this.deps.journal.read(input.taskId, input.operationId)!;
1053
+ if (journal.milestone !== "activation_intent") return journal.milestone as TaskProjectMilestone;
1054
+ return this.resolveIntent(journal, environment);
1055
+ });
1056
+ } else {
1057
+ milestone = journal.milestone;
1058
+ }
1059
+ return { milestone, warning: journal.warning, ...base };
1060
+ });
1061
+ }
1062
+ }