@atolis-hq/wake 0.3.88 → 0.3.89

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.
@@ -55,7 +55,7 @@ export async function createCompositionRoot(wakeRoot, options = {}) {
55
55
  throw new Error('Workspace resource ' + id + ' does not identify a GitHub repository');
56
56
  return 'https://github.com/' + match[1] + '.git';
57
57
  },
58
- });
58
+ }, undefined, undefined, config.execution.workspaceHooks?.prepare);
59
59
  const transcriptStore = config.transcripts.enabled
60
60
  ? (options.transcriptStore ?? new TranscriptStore(paths.transcriptsRoot))
61
61
  : undefined;
@@ -108,4 +108,4 @@ export function resolveWakeVersion(options = {}) {
108
108
  return `g${headHash.slice(0, 7)}`;
109
109
  return '0.1.0-dev';
110
110
  }
111
- export const wakeVersion = "gead96c0";
111
+ export const wakeVersion = "gbc199da";
@@ -31,5 +31,17 @@ export const executionConfigSchema = z
31
31
  leaseDurationMs: z.number().int().positive().optional(),
32
32
  leaseRenewalIntervalMs: z.number().int().positive().optional(),
33
33
  maxAmbiguityReconciliationAttempts: z.number().int().positive().optional(),
34
+ workspaceHooks: z
35
+ .object({
36
+ prepare: z
37
+ .object({
38
+ command: z.string().trim().min(1),
39
+ timeoutMs: z.number().int().positive().default(300_000),
40
+ })
41
+ .strict()
42
+ .optional(),
43
+ })
44
+ .strict()
45
+ .optional(),
34
46
  })
35
47
  .strict();
@@ -2,24 +2,26 @@ import { spawn } from 'node:child_process';
2
2
  // Agent CLIs can emit arbitrarily large machine-readable transcripts. Capture
3
3
  // raw bytes ourselves so overflow never enters a third-party string buffer.
4
4
  const maximumCapturedProcessOutputBytes = 1024 * 1024;
5
- export function runProcess(command, args, cwd, signal, timeoutMs) {
5
+ export function runProcess(command, args, cwd, signal, timeoutMs, shell = false) {
6
6
  const child = spawn(command, args, {
7
7
  ...(cwd === undefined ? {} : { cwd }),
8
- shell: false,
8
+ shell,
9
+ ...(shell && process.platform !== 'win32' ? { detached: true } : {}),
9
10
  stdio: ['ignore', 'pipe', 'pipe'],
10
11
  });
11
- const result = captureProcessOutput(child, signal, timeoutMs);
12
+ const result = captureProcessOutput(child, signal, timeoutMs, shell);
12
13
  return {
13
14
  result,
14
15
  cancel: async () => {
15
- terminate(child);
16
+ terminate(child, shell);
16
17
  },
17
18
  };
18
19
  }
19
- function captureProcessOutput(child, signal, timeoutMs) {
20
+ function captureProcessOutput(child, signal, timeoutMs, shell) {
20
21
  return new Promise((resolve) => {
21
22
  const stdout = [];
22
23
  const stderr = [];
24
+ const combinedOutput = [];
23
25
  let capturedBytes = 0;
24
26
  let timedOut = false;
25
27
  let overflowed = false;
@@ -28,20 +30,24 @@ function captureProcessOutput(child, signal, timeoutMs) {
28
30
  overflowed = true;
29
31
  child.stdout?.destroy();
30
32
  child.stderr?.destroy();
31
- terminate(child);
33
+ terminate(child, shell);
32
34
  };
33
35
  const capture = (destination) => (chunk) => {
34
36
  if (overflowed)
35
37
  return;
36
38
  const remaining = maximumCapturedProcessOutputBytes - capturedBytes;
37
39
  if (remaining <= 0 || chunk.length > remaining) {
38
- if (remaining > 0)
39
- destination.push(chunk.subarray(0, remaining));
40
+ if (remaining > 0) {
41
+ const captured = chunk.subarray(0, remaining);
42
+ destination.push(captured);
43
+ combinedOutput.push(captured);
44
+ }
40
45
  capturedBytes = maximumCapturedProcessOutputBytes;
41
46
  terminateForOverflow();
42
47
  return;
43
48
  }
44
49
  destination.push(chunk);
50
+ combinedOutput.push(chunk);
45
51
  capturedBytes += chunk.length;
46
52
  };
47
53
  child.stdout?.on('data', capture(stdout));
@@ -50,9 +56,9 @@ function captureProcessOutput(child, signal, timeoutMs) {
50
56
  ? undefined
51
57
  : setTimeout(() => {
52
58
  timedOut = true;
53
- terminate(child);
59
+ terminate(child, shell);
54
60
  }, timeoutMs);
55
- const onAbort = () => terminate(child);
61
+ const onAbort = () => terminate(child, shell);
56
62
  signal.addEventListener('abort', onAbort, { once: true });
57
63
  child.once('error', (caught) => {
58
64
  error = caught;
@@ -64,6 +70,7 @@ function captureProcessOutput(child, signal, timeoutMs) {
64
70
  resolve({
65
71
  stdout: Buffer.concat(stdout).toString('utf8'),
66
72
  stderr: error?.message ?? Buffer.concat(stderr).toString('utf8'),
73
+ combinedOutput: Buffer.concat(combinedOutput),
67
74
  exitCode: exitCode ?? undefined,
68
75
  timedOut,
69
76
  ...(overflowed
@@ -76,7 +83,16 @@ function captureProcessOutput(child, signal, timeoutMs) {
76
83
  });
77
84
  });
78
85
  }
79
- function terminate(child) {
86
+ function terminate(child, shell = false) {
87
+ if (shell && process.platform !== 'win32' && child.pid !== undefined) {
88
+ try {
89
+ process.kill(-child.pid);
90
+ return;
91
+ }
92
+ catch {
93
+ // The process may have already exited; fall through to the direct signal.
94
+ }
95
+ }
80
96
  if (!child.killed && child.exitCode === null)
81
97
  child.kill();
82
98
  }
@@ -1,13 +1,25 @@
1
1
  export class FakeWorkspaceProvider {
2
2
  path;
3
3
  branch;
4
+ prepareHook;
5
+ prepareOutcome;
4
6
  requests = [];
5
- constructor(path = '/fake/workspace', branch = 'wake/fake-work') {
7
+ prepareInvocations = [];
8
+ constructor(path = '/fake/workspace', branch = 'wake/fake-work', prepareHook, prepareOutcome = { kind: 'success' }) {
6
9
  this.path = path;
7
10
  this.branch = branch;
11
+ this.prepareHook = prepareHook;
12
+ this.prepareOutcome = prepareOutcome;
8
13
  }
9
14
  async acquire(request) {
10
15
  this.requests.push(request);
16
+ if (this.prepareHook !== undefined) {
17
+ this.prepareInvocations.push({ command: this.prepareHook.command, cwd: this.path });
18
+ if (this.prepareOutcome.kind === 'timed-out')
19
+ throw new Error(`Fake workspace prepare hook (${this.prepareHook.command}) timed out`);
20
+ if (this.prepareOutcome.kind === 'exit' && this.prepareOutcome.exitCode !== 0)
21
+ throw new Error(`Fake workspace prepare hook (${this.prepareHook.command}) exited with code ${this.prepareOutcome.exitCode}`);
22
+ }
11
23
  return {
12
24
  workspaceId: `workspace-${this.requests.length}`,
13
25
  path: this.path,
@@ -3,11 +3,13 @@ import { access, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { RunStatus, WorkspaceMode } from '../../contracts/vocabulary.js';
6
+ import { prepareWorkspace } from './prepare-workspace.js';
6
7
  const exec = promisify(execFile);
7
8
  export class GitWorkspaceProvider {
8
9
  root;
9
10
  resolver;
10
11
  git;
12
+ prepareHook;
11
13
  markerRoot;
12
14
  recoveryFileSystem;
13
15
  constructor(root, resolver, git = async (arguments_) => {
@@ -15,10 +17,11 @@ export class GitWorkspaceProvider {
15
17
  }, recoveryFileSystem = {
16
18
  remove: async (path) => rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }),
17
19
  canonicalize: realpath,
18
- }) {
20
+ }, prepareHook) {
19
21
  this.root = root;
20
22
  this.resolver = resolver;
21
23
  this.git = git;
24
+ this.prepareHook = prepareHook;
22
25
  this.markerRoot = join(this.root, '.wake-workspace-ownership');
23
26
  this.recoveryFileSystem = recoveryFileSystem;
24
27
  }
@@ -44,6 +47,8 @@ export class GitWorkspaceProvider {
44
47
  const branch = request.mode === WorkspaceMode.Branch ? request.workItemId : undefined;
45
48
  if (branch !== undefined)
46
49
  await this.git(['-C', path, 'switch', ...(existingWorkspace ? [] : ['--create']), branch]);
50
+ if (this.prepareHook !== undefined)
51
+ await prepareWorkspace(path, this.prepareHook);
47
52
  return {
48
53
  workspaceId: name,
49
54
  path,
@@ -0,0 +1,16 @@
1
+ import { runProcess } from '../process-execution.js';
2
+ const prepareOutputTailBytes = 4096;
3
+ export async function prepareWorkspace(path, hook) {
4
+ const process = runProcess(hook.command, [], path, new AbortController().signal, hook.timeoutMs, true);
5
+ const result = await process.result;
6
+ if (result.exitCode === 0 && !result.timedOut && result.failureKind === undefined)
7
+ return;
8
+ throw new Error(prepareFailureMessage(hook.command, result));
9
+ }
10
+ function prepareFailureMessage(command, result) {
11
+ const reason = result.timedOut
12
+ ? 'timed out'
13
+ : (result.failureMessage ?? `exited with code ${result.exitCode ?? 'unavailable'}`);
14
+ const output = result.combinedOutput.subarray(-prepareOutputTailBytes).toString('utf8');
15
+ return `Workspace prepare hook (${command}) ${reason}. Combined output tail (last ${prepareOutputTailBytes} bytes):\n${output}`;
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.3.88",
3
+ "version": "0.3.89",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {