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