@prismatic-io/lux 0.0.2-preview.21 → 0.0.2-preview.22

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.
Files changed (46) hide show
  1. package/lib/assertions/rubric/index.d.ts.map +1 -1
  2. package/lib/assertions/rubric/index.js +1 -0
  3. package/lib/assertions/rubric/index.js.map +1 -1
  4. package/lib/assertions/rubric/internal.d.ts.map +1 -1
  5. package/lib/assertions/rubric/internal.js +42 -8
  6. package/lib/assertions/rubric/internal.js.map +1 -1
  7. package/lib/drivers/antigravity/config.d.ts +7 -1
  8. package/lib/drivers/antigravity/config.d.ts.map +1 -1
  9. package/lib/drivers/antigravity/config.js +8 -2
  10. package/lib/drivers/antigravity/config.js.map +1 -1
  11. package/lib/drivers/antigravity/events.d.ts.map +1 -1
  12. package/lib/drivers/antigravity/events.js +54 -6
  13. package/lib/drivers/antigravity/events.js.map +1 -1
  14. package/lib/drivers/antigravity/index.d.ts +8 -0
  15. package/lib/drivers/antigravity/index.d.ts.map +1 -1
  16. package/lib/drivers/antigravity/index.js +4 -3
  17. package/lib/drivers/antigravity/index.js.map +1 -1
  18. package/lib/drivers/codex/config.d.ts +1 -0
  19. package/lib/drivers/codex/config.d.ts.map +1 -1
  20. package/lib/drivers/codex/config.js +16 -2
  21. package/lib/drivers/codex/config.js.map +1 -1
  22. package/lib/drivers/codex/index.d.ts.map +1 -1
  23. package/lib/drivers/codex/index.js +2 -2
  24. package/lib/drivers/codex/index.js.map +1 -1
  25. package/lib/drivers/subprocess/index.js +1 -1
  26. package/lib/drivers/subprocess/index.js.map +1 -1
  27. package/lib/orchestrator/run-execution.d.ts.map +1 -1
  28. package/lib/orchestrator/run-execution.js +15 -3
  29. package/lib/orchestrator/run-execution.js.map +1 -1
  30. package/lib/orchestrator/run-workspace.d.ts +10 -0
  31. package/lib/orchestrator/run-workspace.d.ts.map +1 -0
  32. package/lib/orchestrator/run-workspace.js +58 -0
  33. package/lib/orchestrator/run-workspace.js.map +1 -0
  34. package/package.json +1 -1
  35. package/skills/lux-answerer/SKILL.md +1 -1
  36. package/src/assertions/rubric/index.ts +1 -0
  37. package/src/assertions/rubric/internal.ts +35 -7
  38. package/src/drivers/antigravity/README.md +18 -6
  39. package/src/drivers/antigravity/config.ts +8 -2
  40. package/src/drivers/antigravity/events.ts +61 -6
  41. package/src/drivers/antigravity/index.ts +7 -3
  42. package/src/drivers/codex/config.ts +23 -2
  43. package/src/drivers/codex/index.ts +2 -1
  44. package/src/drivers/subprocess/index.ts +1 -1
  45. package/src/orchestrator/run-execution.ts +16 -3
  46. package/src/orchestrator/run-workspace.ts +62 -0
@@ -232,19 +232,47 @@ export const summarizeEvents = (
232
232
  evidence?: RubricEvidence,
233
233
  ): string => {
234
234
  const lines: string[] = [];
235
- const selected = events.filter((event) => eventSelected(event, evidence));
235
+ const groups: RunEvent[][] = [];
236
+ let previous: RunEvent | undefined;
237
+ for (const event of events) {
238
+ if (eventSelected(event, evidence)) {
239
+ const sameSpeaker =
240
+ event.type === "progress" &&
241
+ previous?.type === "progress" &&
242
+ event.kind === "agent-message" &&
243
+ previous.kind === "agent-message" &&
244
+ event.agent?.id === previous.agent?.id;
245
+ if (sameSpeaker && groups.length > 0) groups.at(-1)?.push(event);
246
+ else groups.push([event]);
247
+ }
248
+ previous = event;
249
+ }
236
250
  // Grading only the tail would silently hide the start of a long run from
237
251
  // the judge; make the omission explicit rather than invisible.
238
- if (selected.length > cap) {
239
- lines.push(`[... ${selected.length - cap} earlier events omitted ...]`);
252
+ if (groups.length > cap) {
253
+ lines.push(`[... ${groups.length - cap} earlier events omitted ...]`);
240
254
  }
241
- const tail = cap === 0 ? [] : selected.slice(-cap);
255
+ const tailGroups = cap === 0 ? [] : groups.slice(-cap);
256
+ const tail = tailGroups.flat();
242
257
  const agentTextAllotments = allotAgentText(tail, evidence?.maxAgentChars ?? 30_000);
243
258
  const toolDetail = evidence?.toolDetail ?? "names";
244
259
  const toolText = allotToolText(tail, evidence?.maxToolChars ?? 20_000, toolDetail);
245
- for (const event of tail) {
246
- const line = eventLine(event, agentTextAllotments, toolText.allotments, toolText.rendered);
247
- if (line !== null) lines.push(line);
260
+ for (const group of tailGroups) {
261
+ if (group.length > 1) {
262
+ const parts: string[] = [];
263
+ for (const event of group) {
264
+ if (event.type !== "progress") continue;
265
+ const text = (event.payload as { text?: unknown })?.text;
266
+ if (typeof text !== "string") continue;
267
+ const take = agentTextAllotments.get(event) ?? 0;
268
+ if (take > 0) parts.push(text.slice(0, take));
269
+ if (take < text.length && parts.at(-1) !== "… [truncated]") parts.push("… [truncated]");
270
+ }
271
+ lines.push(`agent: ${parts.join("")}`);
272
+ } else if (group[0]) {
273
+ const line = eventLine(group[0], agentTextAllotments, toolText.allotments, toolText.rendered);
274
+ if (line !== null) lines.push(line);
275
+ }
248
276
  }
249
277
  return lines.join("\n");
250
278
  };
@@ -27,18 +27,30 @@ Lux writes one `user` event with the case prompt, closes stdin, and reads
27
27
  `init`, `step_update`, and `result` events. Tool steps become paired
28
28
  `tool-call` and `tool-result` events keyed by step index, response deltas become
29
29
  `agent-message` progress, other steps stay visible as `antigravity-step`
30
- progress, and the final `result` usage is recorded as agent usage. A `result`
31
- whose status is not `SUCCESS` is an error; `WAITING` means a tool permission
32
- could not be granted headlessly. Allow the tool in Antigravity's settings or set
33
- `skipPermissions: true` inside a disposable environment.
30
+ progress, and the final `result` usage is recorded as agent usage. The terminal
31
+ response is also retained as message evidence when it was not already streamed.
32
+ Both `DONE` and `ERROR` tool steps close the corresponding tool call; failed
33
+ steps retain their error message. A result with `denied_actions` is an error even
34
+ when Antigravity reports `SUCCESS`. `WAITING` and other non-success statuses are
35
+ also errors. Configure the required scoped permissions in Antigravity before a
36
+ headless evaluation; Lux does not retry a denial with broader permissions.
37
+
38
+ Each run creates a fresh Antigravity project and passes its canonical working
39
+ directory through `--add-dir` and an explicit task-workspace prompt preamble.
40
+ Set `project` to reuse an explicitly configured
41
+ project, and `addDirs` for additional reference directories. Process cwd alone
42
+ does not select an Antigravity project. Project selection does not grant tool
43
+ permissions: Antigravity 1.2.10 can still deny workspace writes in headless mode.
44
+ `skipPermissions` remains false by default and must be explicitly enabled to
45
+ bypass tool permission checks.
34
46
 
35
47
  The headless protocol carries no user questions or permission prompts, so the
36
48
  driver is not interactive and declares no isolation: Antigravity still reads
37
49
  its user settings and conversation history. Controlled experiments are not
38
50
  declared.
39
51
 
40
- The adapter is covered by synthetic protocol and lifecycle tests. It has not
41
- been validated against an authenticated Antigravity session.
52
+ Regression tests cover the `ERROR` tool step followed by `SUCCESS` with denied
53
+ actions observed in an authenticated Antigravity 1.2.10 session.
42
54
 
43
55
  Protocol references:
44
56
 
@@ -8,9 +8,12 @@ export const AntigravityDriverConfigSchema = z
8
8
  model: z.string().min(1),
9
9
  reasoningEffort: z.enum(antigravityCatalog.efforts).optional(),
10
10
  agent: z.string().min(1).optional(),
11
+ /** Use an existing project; otherwise each run creates a fresh project. */
12
+ project: z.string().min(1).optional(),
13
+ addDirs: z.array(z.string().min(1)).default([]),
11
14
  /**
12
15
  * Auto-approve every tool permission. Off by default: a headless turn that
13
- * needs approval ends with a WAITING result and Lux reports it as an error.
16
+ * needs approval can report WAITING or SUCCESS with denied_actions; both fail.
14
17
  */
15
18
  skipPermissions: z.boolean().default(false),
16
19
  /** Enable Antigravity's terminal sandbox restrictions. */
@@ -26,13 +29,16 @@ export const AntigravityDriverConfigSchema = z
26
29
 
27
30
  export type AntigravityDriverConfig = z.infer<typeof AntigravityDriverConfigSchema>;
28
31
 
29
- export const buildAntigravityArgs = (config: AntigravityDriverConfig): string[] => [
32
+ export const buildAntigravityArgs = (config: AntigravityDriverConfig, cwd?: string): string[] => [
30
33
  "--input-format",
31
34
  "stream-json",
32
35
  "--output-format",
33
36
  "stream-json",
34
37
  "--model",
35
38
  config.model,
39
+ ...(config.project ? ["--project", config.project] : ["--new-project"]),
40
+ ...(cwd ? ["--add-dir", cwd] : []),
41
+ ...config.addDirs.flatMap((directory) => ["--add-dir", directory]),
36
42
  ...(config.reasoningEffort ? ["--effort", config.reasoningEffort] : []),
37
43
  ...(config.agent ? ["--agent", config.agent] : []),
38
44
  ...(config.skipPermissions ? ["--dangerously-skip-permissions"] : []),
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { z } from "zod";
2
3
  import {
3
4
  type ParsedDriverEvent,
@@ -38,6 +39,9 @@ const ResultSchema = z.looseObject({
38
39
  status: z.string().optional(),
39
40
  response: z.string().optional(),
40
41
  error: z.string().optional(),
42
+ denied_actions: z
43
+ .array(z.looseObject({ action: z.string(), display_name: z.string().optional() }))
44
+ .optional(),
41
45
  num_turns: z.number().optional(),
42
46
  usage: z.unknown().optional(),
43
47
  });
@@ -64,8 +68,8 @@ const parseUsage = (raw: unknown): TokenUsage | null => {
64
68
  const usage = UsageSchema.safeParse(raw).data;
65
69
  if (!usage) return null;
66
70
  return {
67
- input: Math.max(0, usage.input_tokens - usage.cache_read_tokens),
68
- output: usage.output_tokens + usage.thinking_tokens,
71
+ input: usage.input_tokens,
72
+ output: usage.output_tokens,
69
73
  cacheRead: usage.cache_read_tokens,
70
74
  cacheCreation: 0,
71
75
  };
@@ -125,7 +129,7 @@ const toolStepEvents = (
125
129
  ),
126
130
  });
127
131
  }
128
- if (step.state === "DONE") {
132
+ if (step.state === "DONE" || step.state === "ERROR") {
129
133
  openTools.delete(step.step_index);
130
134
  const error = step.tool_info?.error;
131
135
  events.push({
@@ -136,8 +140,12 @@ const toolStepEvents = (
136
140
  name,
137
141
  toolUseId,
138
142
  content: step.tool_info?.output ?? null,
139
- isError: error !== undefined,
140
- text: error?.message ?? outputText(step.tool_info?.output),
143
+ isError: step.state === "ERROR" || error !== undefined,
144
+ text:
145
+ error?.message ??
146
+ (step.state === "ERROR"
147
+ ? "Antigravity tool failed"
148
+ : outputText(step.tool_info?.output)),
141
149
  },
142
150
  ts,
143
151
  ),
@@ -179,6 +187,21 @@ const resultEvent = (
179
187
  conversationId: string | undefined,
180
188
  usage: TokenUsage | null,
181
189
  ): ParsedDriverEvent => {
190
+ if (result?.denied_actions?.length) {
191
+ return {
192
+ tag: "error",
193
+ error: {
194
+ exitReason: "error",
195
+ reason: `Antigravity could not complete because tool permissions were denied: ${result.denied_actions.map(({ action }) => action).join(", ")}`,
196
+ summary: {
197
+ deniedActions: result.denied_actions,
198
+ ...(result.response !== undefined ? { response: result.response } : {}),
199
+ ...(conversationId ? { conversationId } : {}),
200
+ ...(usage ? { usage } : { usageAvailability: "unavailable" }),
201
+ },
202
+ },
203
+ };
204
+ }
182
205
  if (result?.status === "SUCCESS") {
183
206
  return {
184
207
  tag: "done",
@@ -211,6 +234,8 @@ export const makeParseEvent = (config: AntigravityDriverConfig, cliVersion?: str
211
234
  const openTools = new Set<number>();
212
235
  let conversationId: string | undefined;
213
236
  let usage: TokenUsage | null = null;
237
+ let responseStep: number | undefined;
238
+ let responseHash = createHash("sha256");
214
239
  return (line: string): ParsedDriverEvent | ParsedDriverEvent[] | null => {
215
240
  let raw: unknown;
216
241
  try {
@@ -228,8 +253,38 @@ export const makeParseEvent = (config: AntigravityDriverConfig, cliVersion?: str
228
253
  if (event.event === "step_update") {
229
254
  if (!event.step_update) return null;
230
255
  usage = parseUsage(event.step_update.usage) ?? usage;
256
+ if (event.step_update.step_type === "agent_response") {
257
+ if (responseStep !== event.step_update.step_index) {
258
+ responseHash = createHash("sha256");
259
+ responseStep = event.step_update.step_index;
260
+ }
261
+ responseHash.update(event.step_update.text_delta ?? "");
262
+ }
231
263
  return stepEvents(event.step_update, openTools, Date.now());
232
264
  }
233
- return resultEvent(event.result, conversationId, parseUsage(event.result?.usage) ?? usage);
265
+ const terminal = resultEvent(
266
+ event.result,
267
+ conversationId,
268
+ parseUsage(event.result?.usage) ?? usage,
269
+ );
270
+ if (
271
+ event.result?.response &&
272
+ createHash("sha256").update(event.result.response).digest("hex") !==
273
+ responseHash.copy().digest("hex")
274
+ ) {
275
+ return [
276
+ {
277
+ tag: "progress",
278
+ progress: {
279
+ id: "antigravity-final-response",
280
+ kind: "agent-message",
281
+ payload: { text: event.result.response },
282
+ ts: Date.now(),
283
+ },
284
+ },
285
+ terminal,
286
+ ];
287
+ }
288
+ return terminal;
234
289
  };
235
290
  };
@@ -1,3 +1,4 @@
1
+ import { realpath } from "node:fs/promises";
1
2
  import {
2
3
  type AgentDriver,
3
4
  type Answer,
@@ -42,17 +43,20 @@ class AntigravityDriver implements AgentDriver {
42
43
 
43
44
  async start(ctx: StartContext): Promise<ReadyState> {
44
45
  if (this.inner) throw new Error("Antigravity driver cannot be started twice");
45
- this.cwd = this.config.cwd ?? ctx.artifactsDir;
46
+ this.cwd = await realpath(this.config.cwd ?? ctx.artifactsDir);
46
47
  this.artifactsDir = ctx.artifactsDir;
47
48
  const cliVersion = await discoverCliVersion(this.config.command);
48
49
  const inner = await subprocessDriver.create({
49
50
  command: this.config.command,
50
- args: buildAntigravityArgs(this.config),
51
+ args: buildAntigravityArgs(this.config, this.cwd),
51
52
  cwd: this.cwd,
52
53
  env: this.config.env,
53
54
  protocol: "custom",
54
55
  parseEvent: makeParseEvent(this.config, cliVersion),
55
- formatPrompt: formatAntigravityPrompt,
56
+ formatPrompt: (prompt) =>
57
+ formatAntigravityPrompt(
58
+ `The task workspace is ${JSON.stringify(this.cwd)}. Treat that directory as the current directory for this task and create task artifacts there.\n\n${prompt}`,
59
+ ),
56
60
  closeStdinAfterPrompt: true,
57
61
  idleTimeoutMs: this.config.idleTimeoutMs,
58
62
  maxLineBytes: this.config.maxLineBytes,
@@ -115,6 +115,27 @@ const tomlValue = (value: CodexConfigValue): string => {
115
115
  .join(",")}}`;
116
116
  };
117
117
 
118
+ // Codex shell snapshots can replace the launching process's PATH. Pin only
119
+ // executable-search variables here; never put other environment secrets in argv.
120
+ export const codexConfigOverrides = (
121
+ config: CodexDriverConfig,
122
+ ): Record<string, CodexConfigValue> => {
123
+ const authored = config.config ?? {};
124
+ const paths = Object.fromEntries(
125
+ Object.entries(config.env ?? {}).filter(
126
+ ([key]) => key.toUpperCase() === "PATH" || key.toUpperCase() === "PATHEXT",
127
+ ),
128
+ );
129
+ if (Object.keys(paths).length === 0) return authored;
130
+ const object = (value: CodexConfigValue | undefined): Record<string, CodexConfigValue> =>
131
+ value && typeof value === "object" && !Array.isArray(value) ? value : {};
132
+ const policy = object(authored.shell_environment_policy);
133
+ return {
134
+ ...authored,
135
+ shell_environment_policy: { ...policy, set: { ...paths, ...object(policy.set) } },
136
+ };
137
+ };
138
+
118
139
  export const buildExecArgs = (config: CodexDriverConfig): string[] => {
119
140
  const args = ["exec", "--json", "--color", "never", "--sandbox", config.sandbox];
120
141
  args.push("--model", config.model);
@@ -125,7 +146,7 @@ export const buildExecArgs = (config: CodexDriverConfig): string[] => {
125
146
  if (config.ignoreRules) args.push("--ignore-rules");
126
147
  if (config.strictConfig) args.push("--strict-config");
127
148
  for (const directory of config.addDirs ?? []) args.push("--add-dir", directory);
128
- for (const [key, value] of Object.entries(config.config ?? {})) {
149
+ for (const [key, value] of Object.entries(codexConfigOverrides(config))) {
129
150
  args.push("--config", `${key}=${tomlValue(value)}`);
130
151
  }
131
152
  args.push(...effortArgs(config));
@@ -137,7 +158,7 @@ export const buildExecArgs = (config: CodexDriverConfig): string[] => {
137
158
  export const buildAppServerArgs = (config: CodexDriverConfig): string[] => {
138
159
  const args = ["app-server", "--stdio"];
139
160
  if (config.strictConfig) args.push("--strict-config");
140
- for (const [key, value] of Object.entries(config.config ?? {})) {
161
+ for (const [key, value] of Object.entries(codexConfigOverrides(config))) {
141
162
  args.push("--config", `${key}=${tomlValue(value)}`);
142
163
  }
143
164
  args.push(...effortArgs(config));
@@ -41,6 +41,7 @@ import {
41
41
  type CodexConfigValue,
42
42
  type CodexDriverConfig,
43
43
  CodexDriverConfigSchema,
44
+ codexConfigOverrides,
44
45
  } from "./config.js";
45
46
 
46
47
  import { makeParseEvent } from "./exec-events.js";
@@ -189,7 +190,7 @@ class CodexAppServerDriverImpl implements AgentDriver {
189
190
  ? await Promise.all(this.config.isolation.allowRead.map((path) => realpath(path)))
190
191
  : [];
191
192
  const threadConfig: Record<string, CodexConfigValue> = {
192
- ...(this.config.config ?? {}),
193
+ ...codexConfigOverrides(this.config),
193
194
  ...(this.config.reasoningEffort
194
195
  ? { model_reasoning_effort: this.config.reasoningEffort }
195
196
  : {}),
@@ -148,7 +148,7 @@ class SubprocessDriverImpl implements AgentDriver {
148
148
  throw new Error(`subprocess fixture values exceed ${MAX_FIXTURE_VALUES_BYTES} bytes`);
149
149
  }
150
150
  const managed = spawnManagedProcess(this.config.command, this.config.args ?? [], {
151
- cwd: this.config.cwd ?? ctx.runDir,
151
+ cwd: this.config.cwd ?? ctx.artifactsDir,
152
152
  env: {
153
153
  ...(this.config.inheritEnv === false ? {} : process.env),
154
154
  ...this.config.env,
@@ -53,6 +53,7 @@ import {
53
53
  type RunFailurePhase,
54
54
  type RunLifecycleEffects,
55
55
  } from "./run-lifecycle.js";
56
+ import { RunWorkspace } from "./run-workspace.js";
56
57
  import { isRunTraceLimit, type RunTraceLimitFailure } from "./trace-limits.js";
57
58
 
58
59
  /** Sentinel `casePath` for a pre-loaded case that has no file behind it. */
@@ -313,6 +314,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
313
314
  private metadata: RunMetadata | undefined;
314
315
  private usage: RunUsage | undefined;
315
316
  private run: Run | undefined;
317
+ private workspace: RunWorkspace | undefined;
316
318
 
317
319
  private constructor(
318
320
  args: RunWithPluginsArgs,
@@ -369,10 +371,11 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
369
371
  const { request, evalCase, casePath, driver } = this.args;
370
372
  this.lifecycle("run-loop");
371
373
  if (request.abortSignal?.aborted) return;
374
+ this.workspace = await RunWorkspace.create();
372
375
  const staged = await stageFixtures(evalCase.fixtures, {
373
376
  ...(request.fixturesRoot ? { fixturesRoot: request.fixturesRoot } : {}),
374
377
  casePath,
375
- artifactsDir: this.runDir.artifactsDir,
378
+ artifactsDir: this.workspace.path,
376
379
  });
377
380
  this.fixturesHash = staged?.hash;
378
381
  if (request.abortSignal?.aborted) return;
@@ -387,7 +390,7 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
387
390
  ...(this.args.fixtureValues.length > 0 ? { fixtureValues: this.args.fixtureValues } : {}),
388
391
  runsRoot: resolve(request.runsRoot),
389
392
  runDir: this.runDir.path,
390
- artifactsDir: this.runDir.artifactsDir,
393
+ artifactsDir: this.workspace.path,
391
394
  abortSignal: request.abortSignal ?? new AbortController().signal,
392
395
  });
393
396
  this.append({ type: "ready", ...this.ready });
@@ -463,6 +466,12 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
463
466
  }
464
467
 
465
468
  async close(primaryError: unknown | null): Promise<void> {
469
+ if (this.workspace) {
470
+ await closeQuietly(async () => {
471
+ await this.args.driver.close();
472
+ await this.workspace?.archive(this.runDir.artifactsDir);
473
+ }, `run workspace ${this.workspace.path}`);
474
+ }
466
475
  if (primaryError instanceof Error) {
467
476
  (primaryError as Error & RunDirCarrier).luxRunDir = this.runDir.path;
468
477
  }
@@ -567,7 +576,11 @@ class RunExecution implements RunLifecycleEffects<RunResult> {
567
576
  if (!this.ready) return { artifacts: [] };
568
577
  try {
569
578
  await this.args.driver.quiesce?.();
570
- return { artifacts: await this.args.driver.collect() };
579
+ const artifacts = await this.args.driver.collect();
580
+ return {
581
+ artifacts:
582
+ (await this.workspace?.archive(this.runDir.artifactsDir, artifacts)) ?? artifacts,
583
+ };
571
584
  } catch (error) {
572
585
  const reason = errorMessage(error);
573
586
  this.runDir.events.append({
@@ -0,0 +1,62 @@
1
+ import { cp, lstat, mkdir, mkdtemp, realpath, rename, rm, rmdir } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import type { Artifact } from "../core/index.js";
5
+
6
+ /** Keep package-manager discovery and repository ignore rules outside subject workspaces. */
7
+ export class RunWorkspace {
8
+ private archived = false;
9
+ readonly path: string;
10
+
11
+ private constructor(path: string) {
12
+ this.path = path;
13
+ }
14
+
15
+ static async create(): Promise<RunWorkspace> {
16
+ const root = join(tmpdir(), `lux-workspaces-${process.getuid?.() ?? "user"}`);
17
+ await mkdir(root, { recursive: true, mode: 0o700 });
18
+ const metadata = await lstat(root);
19
+ if (
20
+ !metadata.isDirectory() ||
21
+ metadata.isSymbolicLink() ||
22
+ (process.getuid && metadata.uid !== process.getuid())
23
+ ) {
24
+ throw new Error(
25
+ `Lux workspace root must be a real directory owned by the current user: ${root}`,
26
+ );
27
+ }
28
+ return new RunWorkspace(await realpath(await mkdtemp(join(root, "run-"))));
29
+ }
30
+
31
+ async archive(destination: string, artifacts: Artifact[] = []): Promise<Artifact[]> {
32
+ if (!this.archived) {
33
+ const metadata = await lstat(this.path);
34
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
35
+ throw new Error(`run workspace must remain a real directory: ${this.path}`);
36
+ }
37
+ await rmdir(destination).catch((error: NodeJS.ErrnoException) => {
38
+ if (error.code !== "ENOENT") throw error;
39
+ });
40
+ try {
41
+ await rename(this.path, destination);
42
+ } catch (error) {
43
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
44
+ await cp(this.path, destination, { recursive: true, verbatimSymlinks: true });
45
+ await rm(this.path, { recursive: true });
46
+ }
47
+ this.archived = true;
48
+ }
49
+ return artifacts.map((artifact) => {
50
+ if (!artifact.root) return artifact;
51
+ const path = relative(this.path, resolve(artifact.root));
52
+ if (path === "") {
53
+ const { root: _root, ...captured } = artifact;
54
+ return captured;
55
+ }
56
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) {
57
+ return artifact;
58
+ }
59
+ return { ...artifact, root: join(destination, path) };
60
+ });
61
+ }
62
+ }