@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.
- package/dist/src/bootstrap/composition-root.js +1 -1
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/execution/contracts/config.js +12 -0
- package/dist/src/execution/infrastructure/process-execution.js +27 -11
- package/dist/src/execution/infrastructure/workspace/fake-workspace.js +13 -1
- package/dist/src/execution/infrastructure/workspace/git-workspace.js +6 -1
- package/dist/src/execution/infrastructure/workspace/prepare-workspace.js +16 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|