workbite 0.1.12 → 0.1.13
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/README.md +29 -5
- package/lib/agent.js +47 -17
- package/lib/archive.js +110 -9
- package/lib/cli.js +78 -7
- package/lib/diagnostics.js +234 -0
- package/lib/install.js +39 -4
- package/lib/mcp.js +133 -22
- package/lib/runtime.js +167 -89
- package/lib/startup-lock.js +71 -0
- package/lib/transfer.js +113 -0
- package/package.json +1 -1
- package/release.json +12 -19
- package/skills/workbite-native/SKILL.md +18 -3
- package/skills/workbite-native/references/commands.md +26 -12
- package/skills/workbite-native/references/task-fields.md +208 -0
- package/skills/workbite-native/references/transfer.md +204 -0
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
|
|
|
37
37
|
`github.com/Shinick-Han/WorkBite-releases`, verifies its size and SHA-256 and
|
|
38
38
|
every file listed in its `bundle.json`, and installs it into an immutable
|
|
39
39
|
per-user version folder. No auto-update, no install scripts, no dependencies.
|
|
40
|
-
- `workbite check` re-verifies
|
|
40
|
+
- `workbite check` re-verifies installed bundle integrity without downloading;
|
|
41
|
+
it does not test backend health, workspace admission or UI behavior.
|
|
41
42
|
- `workbite open --workspace ABS [--init] [--no-ui] [--frontend typescript|rust]`
|
|
42
43
|
starts or reuses the workspace's Work Stack owner and a StackBite backend
|
|
43
44
|
(only processes it can prove it started), then runs the TypeScript Work Stack
|
|
@@ -52,10 +53,13 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
|
|
|
52
53
|
- Each backend's startup record (private token, launch id, port) is saved in
|
|
53
54
|
the state folder *before* the process is started. A later `open` reuses
|
|
54
55
|
that same attempt once it authenticates (also after a slow start), and
|
|
55
|
-
otherwise refuses
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
otherwise refuses. Unreadable/invalid records remain untouched; an
|
|
57
|
+
unreachable process is not assumed stopped. The unreleased source's narrow
|
|
58
|
+
archival path requires trusted same-host previous-boot evidence for eligible
|
|
59
|
+
owner/backend records. It does not resolve foreign hosts or native intents.
|
|
60
|
+
Diagnose the exact host/boot, workspace, backend and native intent before
|
|
61
|
+
operator handover. Age or a missing local PID cannot establish that a process
|
|
62
|
+
on another NFS client has stopped; preserve uncertain records.
|
|
59
63
|
|
|
60
64
|
WorkBite keeps its own state (`%LOCALAPPDATA%\WorkBite` on Windows,
|
|
61
65
|
`~/.local/share/workbite` and `~/.local/state/workbite` on Linux; override
|
|
@@ -63,6 +67,26 @@ with absolute `WORKBITE_HOME` / `WORKBITE_STATE_DIR`) and never touches
|
|
|
63
67
|
separately installed Work Stack or StackBite apps, their state or ports. It
|
|
64
68
|
never approves provider trust or starts paid agents by itself.
|
|
65
69
|
|
|
70
|
+
Startup diagnostics in the **unreleased source** add `workbite status [--json]`,
|
|
71
|
+
`workbite doctor [--json]` and `workbite recover --dry-run [--json]`. Check your
|
|
72
|
+
installed launcher's `--help` before using them. They inspect evidence and plan
|
|
73
|
+
only: no process starts/stops, record reset, native intent repair or migration.
|
|
74
|
+
`open --no-ui` does start/reuse the backend stack (including native selection for
|
|
75
|
+
v2) and prints JSON, but never launches or validates the foreground UI. Startup
|
|
76
|
+
stage messages distinguish that UI launch from backend readiness; the launcher
|
|
77
|
+
continues waiting while the UI runs. Full installed-file hashing still occurs
|
|
78
|
+
on reuse, so these changes do not promise faster opens.
|
|
79
|
+
|
|
80
|
+
For NFS shared homes, configure the existing absolute `WORKBITE_STATE_DIR`
|
|
81
|
+
override to a distinct host-local runtime directory per host after coordinating
|
|
82
|
+
any existing owners and active Seats. This does not move old state or permit
|
|
83
|
+
parallel writers to one shared workspace. `WORKBITE_HOME` separately overrides
|
|
84
|
+
the installation root (`app/` beneath it); changing it is not a universal tar
|
|
85
|
+
fix. See the source repository's
|
|
86
|
+
[startup troubleshooting guide](../../docs/STARTUP-TROUBLESHOOTING.md) for tcsh
|
|
87
|
+
`setenv` examples, failure diagnosis, bounded tar stderr and safe handover
|
|
88
|
+
requirements. Keep workspace locks and unknown native intents intact.
|
|
89
|
+
|
|
66
90
|
Coding agents read and record Tasks of a workspace whose owner `workbite open`
|
|
67
91
|
already runs; nothing is started for them:
|
|
68
92
|
|
package/lib/agent.js
CHANGED
|
@@ -14,6 +14,7 @@ const OWNER_SCHEMA = 'workbite.owner.v2';
|
|
|
14
14
|
const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
|
|
15
15
|
const LAUNCH = /^[0-9a-f]{16}$/;
|
|
16
16
|
const MAX_PACKET_BYTES = 32768;
|
|
17
|
+
const MAX_TRANSFER_BYTES = 1024 * 1024;
|
|
17
18
|
const MAX_ARGV = 128;
|
|
18
19
|
const MAX_STDOUT = 1024 * 1024 + 8192;
|
|
19
20
|
const MAX_STDERR = 65536;
|
|
@@ -24,8 +25,8 @@ const NO_UID = '00000000-0000-4000-8000-000000000000';
|
|
|
24
25
|
|
|
25
26
|
// Curated command paths; every other native command (raw rpc, legacy, deploy, delete, migration) is refused.
|
|
26
27
|
const AGENT_COMMANDS = Object.freeze(['capabilities', 'status', 'tasks list', 'tasks create', 'tasks update', 'context',
|
|
27
|
-
'checkpoint', 'operation-status']);
|
|
28
|
-
const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint']);
|
|
28
|
+
'checkpoint', 'operation-status', 'export', 'import', 'import-status', 'import-rollback']);
|
|
29
|
+
const WRITE_COMMANDS = Object.freeze(['tasks create', 'tasks update', 'checkpoint', 'import', 'import-rollback']);
|
|
29
30
|
const FORBIDDEN_FLAGS = new Set(['--data-dir', '--endpoint', '--token-env', '--token', '--workspace', '--read-retries',
|
|
30
31
|
'--timeout-ms', '--request-id', '--generation', '--packet-file']);
|
|
31
32
|
|
|
@@ -61,13 +62,28 @@ function checkedArgv(argv, uid) {
|
|
|
61
62
|
});
|
|
62
63
|
return command;
|
|
63
64
|
}
|
|
64
|
-
function checkedStdin(stdin) {
|
|
65
|
+
function checkedStdin(stdin, command) {
|
|
65
66
|
if (stdin === undefined || stdin === null) return null;
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const maximum = command === 'import' ? MAX_TRANSFER_BYTES : MAX_PACKET_BYTES;
|
|
68
|
+
if (typeof stdin !== 'string' || Buffer.byteLength(stdin) > maximum || stdin.includes('\0') || stdin.isWellFormed?.() === false) {
|
|
69
|
+
fail(`WorkBite agent input must be UTF-8 text of at most ${maximum} bytes`);
|
|
68
70
|
}
|
|
69
71
|
return stdin;
|
|
70
72
|
}
|
|
73
|
+
// Capture recovery identity before launch; never reconstruct it from native error text.
|
|
74
|
+
function checkedIntent(argv, command, write) {
|
|
75
|
+
const flags = argv.filter(item => item.split('=')[0] === '--intent-id');
|
|
76
|
+
if (!flags.length && !write) return null;
|
|
77
|
+
if (flags.length !== 1 || (!WRITE_COMMANDS.includes(command)
|
|
78
|
+
&& !['operation-status', 'import-status'].includes(command)) || argv.includes('--')) {
|
|
79
|
+
fail('Invalid or repeated --intent-id');
|
|
80
|
+
}
|
|
81
|
+
const flag = flags[0];
|
|
82
|
+
const value = flag.includes('=') ? flag.slice(flag.indexOf('=') + 1) : argv[argv.indexOf(flag) + 1];
|
|
83
|
+
if (typeof value !== 'string' || (!flag.includes('=') && value.startsWith('--'))
|
|
84
|
+
|| value.length < 8 || value.length > 128 || /[^A-Za-z0-9._:-]/.test(value)) fail('Invalid --intent-id');
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
71
87
|
function clientCommand(installed, commands) {
|
|
72
88
|
const command = commands?.workstack_client || [installed?.executables?.workstack_client];
|
|
73
89
|
if (!Array.isArray(command) || typeof command[0] !== 'string' || !path.isAbsolute(command[0])) {
|
|
@@ -136,17 +152,27 @@ async function recheck(binding, state) {
|
|
|
136
152
|
}
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
|
|
155
|
+
// JSON quoting retains the exact path without letting terminal controls become diagnostics.
|
|
156
|
+
const diagnosticJson = value => JSON.stringify(value).replace(/[\x7f-\x9f\u2028-\u202e\u2066-\u2069]/g,
|
|
157
|
+
character => `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
|
140
158
|
// One request, one response, no retry. `write` decides how an uncertain outcome is reported.
|
|
141
|
-
function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write }) {
|
|
159
|
+
function rpc(command, { endpoint, uid, workspace, environment, argv, stdin, deadline, write, intent, name }) {
|
|
142
160
|
const requestId = `workbite-agent-${randomBytes(8).toString('hex')}`;
|
|
143
161
|
const request = JSON.stringify({ version: 1, request_id: requestId, workspace_uid: uid, generation: 0,
|
|
144
162
|
method: 'cli.execute', params: { argv, stdin } }) + '\n';
|
|
145
163
|
const nativeTimeout = Math.max(1000, Math.min(300000, deadline));
|
|
146
164
|
const [file, ...prefix] = command;
|
|
165
|
+
// cli.execute wraps escaped stdout in JSON; a bounded archive may expand at both layers.
|
|
166
|
+
const transfer = ['export', 'import', 'import-status', 'import-rollback'].includes(name);
|
|
167
|
+
const stdoutLimit = transfer ? 8 * MAX_TRANSFER_BYTES : MAX_STDOUT;
|
|
168
|
+
const lookup = transfer ? 'import-status' : 'operation-status';
|
|
169
|
+
const intentFlag = intent?.startsWith('--') ? `--intent-id=${intent}` : `--intent-id ${intent}`;
|
|
147
170
|
const uncertain = what => ({ code: 1, stdout: '', stderr: write
|
|
148
|
-
? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent
|
|
149
|
-
|
|
171
|
+
? `error: commit unknown (${what}); WorkBite did not replay it. Check: workbite agent ${lookup} ${intentFlag}\n`
|
|
172
|
+
+ `Use the same bound workspace: ${diagnosticJson({ workspace, workspace_uid: uid })}\n`
|
|
173
|
+
: `error: ${what}; nothing was retried\n`,
|
|
174
|
+
...(write ? { meta: { command: `agent.${name.replace(/ /g, '.')}`, intent_id: intent, commit_state: 'unknown' },
|
|
175
|
+
workbite: { schema: 'workbite.agent.binding.v1', workspace, workspace_uid: uid } } : {}) });
|
|
150
176
|
return new Promise(resolve => {
|
|
151
177
|
let child;
|
|
152
178
|
try {
|
|
@@ -160,7 +186,7 @@ function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write
|
|
|
160
186
|
const timer = setTimeout(() => { child.kill(); finish(uncertain('native client deadline exceeded')); }, nativeTimeout + 5000);
|
|
161
187
|
child.stdout.on('data', chunk => {
|
|
162
188
|
outSize += chunk.length;
|
|
163
|
-
if (outSize >
|
|
189
|
+
if (outSize > stdoutLimit) { overflow = true; child.kill(); } else out.push(chunk);
|
|
164
190
|
});
|
|
165
191
|
child.stderr.on('data', chunk => { errSize += chunk.length; if (errSize <= MAX_STDERR) err.push(chunk); });
|
|
166
192
|
child.stdin.on('error', () => {});
|
|
@@ -176,9 +202,10 @@ function rpc(command, { endpoint, uid, environment, argv, stdin, deadline, write
|
|
|
176
202
|
finish(uncertain('no valid native response')); return;
|
|
177
203
|
}
|
|
178
204
|
if (response.error) {
|
|
179
|
-
const code =
|
|
205
|
+
const code = ['InvalidRequest', 'UnsupportedVersion', 'Unavailable', 'Timeout', 'Conflict', 'Unauthorized', 'Io', 'UnknownCommit']
|
|
206
|
+
.includes(response.error.code) ? response.error.code : 'Error';
|
|
180
207
|
if (code === 'UnknownCommit') { finish(uncertain('owner response was lost')); return; }
|
|
181
|
-
finish({ code: 1, stdout: '', stderr: `error: ${code}:
|
|
208
|
+
finish({ code: 1, stdout: '', stderr: `error: ${code}: native request failed\n` });
|
|
182
209
|
return;
|
|
183
210
|
}
|
|
184
211
|
const result = response.result;
|
|
@@ -194,17 +221,20 @@ async function executeAgent(binding, argv, { stdin } = {}) {
|
|
|
194
221
|
const state = bindings.get(binding);
|
|
195
222
|
if (!state) fail('Not a WorkBite agent binding; use bindAgent');
|
|
196
223
|
const command = checkedArgv(argv, binding.workspaceUid);
|
|
197
|
-
const
|
|
224
|
+
const args = [...argv];
|
|
225
|
+
const input = checkedStdin(stdin, command);
|
|
226
|
+
const write = WRITE_COMMANDS.includes(command) && !(command === 'import' && args.includes('--dry-run'));
|
|
227
|
+
const intent = checkedIntent(args, command, write);
|
|
198
228
|
await recheck(binding, state);
|
|
199
|
-
return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid,
|
|
200
|
-
argv:
|
|
229
|
+
return rpc(state.command, { endpoint: binding.endpoint, uid: binding.workspaceUid, workspace: binding.workspace,
|
|
230
|
+
environment: state.environment, argv: args, stdin: input, deadline: state.deadline, write, intent, name: command });
|
|
201
231
|
}
|
|
202
232
|
|
|
203
233
|
// The static capability table needs no workspace authority; it never reaches an owner.
|
|
204
234
|
async function agentCapabilities({ installed, commands, environment = process.env, argv = ['agent', 'capabilities'], timeouts = {} } = {}) {
|
|
205
235
|
if (checkedArgv(argv, NO_UID) !== 'capabilities') fail('Only agent capabilities is authority-free');
|
|
206
236
|
return rpc(clientCommand(installed, commands), { endpoint: NO_ENDPOINT, uid: NO_UID, environment, argv: [...argv], stdin: null,
|
|
207
|
-
deadline: timeouts.agent || DEFAULT_DEADLINE, write: false });
|
|
237
|
+
deadline: timeouts.agent || DEFAULT_DEADLINE, write: false, name: 'capabilities' });
|
|
208
238
|
}
|
|
209
239
|
|
|
210
|
-
module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, commandOf };
|
|
240
|
+
module.exports = { bindAgent, executeAgent, agentCapabilities, AGENT_COMMANDS, WRITE_COMMANDS, MAX_PACKET_BYTES, MAX_TRANSFER_BYTES, commandOf };
|
package/lib/archive.js
CHANGED
|
@@ -9,6 +9,7 @@ const { Transform, Writable } = require('node:stream');
|
|
|
9
9
|
const { pipeline } = require('node:stream/promises');
|
|
10
10
|
const { spawn } = require('node:child_process');
|
|
11
11
|
const path = require('node:path');
|
|
12
|
+
const { stripVTControlCharacters } = require('node:util');
|
|
12
13
|
const { LIMITS, fail, memberPath } = require('./common');
|
|
13
14
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
14
15
|
const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
@@ -213,21 +214,121 @@ async function boundedGunzip(archive, destination) {
|
|
|
213
214
|
function nativeTar() {
|
|
214
215
|
return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
|
|
215
216
|
}
|
|
216
|
-
|
|
217
|
+
// Diagnostics are untrusted subprocess output or paths. Never echo URLs, auth
|
|
218
|
+
// headers, credential assignments or terminal control sequences back to a terminal.
|
|
219
|
+
function diagnosticText(value, maximum = 4096) {
|
|
220
|
+
return stripVTControlCharacters(String(value))
|
|
221
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
222
|
+
.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted URL]')
|
|
223
|
+
.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*/gi, '[redacted credentials]')
|
|
224
|
+
.replace(/\b(?:bearer|basic)\s+\S+/gi, '[redacted credentials]')
|
|
225
|
+
.replace(/\b([\w.-]*(?:token|password|passwd|secret|credential|api[_-]?key)[\w.-]*)\s*[:=]\s*(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\s]*)/gi, '$1=[redacted]')
|
|
226
|
+
.slice(0, maximum);
|
|
227
|
+
}
|
|
228
|
+
function extractionError(reason, destination, { code = null, signal = null, stderr = '', truncated = false, spawnCode } = {}) {
|
|
229
|
+
const detail = diagnosticText(stderr);
|
|
230
|
+
const permission = /permission denied|operation not permitted|read-only file system|access is denied/i.test(detail)
|
|
231
|
+
|| ['EACCES', 'EPERM', 'EROFS'].includes(spawnCode);
|
|
232
|
+
const storage = /no space left|disk (?:is )?full|quota exceeded|stale file handle|input\/output error/i.test(detail)
|
|
233
|
+
|| ['ENOSPC', 'EDQUOT', 'ESTALE', 'EIO'].includes(spawnCode);
|
|
234
|
+
const hint = permission ? ' Check destination permissions, mount options and NFS access.'
|
|
235
|
+
: storage ? ' Check free space, quota and filesystem/NFS health.' : '';
|
|
236
|
+
const error = new Error(`${reason} (exit code: ${code ?? 'none'}, signal: ${diagnosticText(signal ?? 'none', 64)}${spawnCode ? `, spawn: ${diagnosticText(spawnCode, 64)}` : ''}); destination: ${diagnosticText(path.resolve(destination))}.${hint}${detail ? ` stderr: ${detail}${truncated ? ' [truncated]' : ''}` : ''}`);
|
|
237
|
+
error.exitCode = code;
|
|
238
|
+
error.signal = signal;
|
|
239
|
+
if (spawnCode) error.code = spawnCode;
|
|
240
|
+
return error;
|
|
241
|
+
}
|
|
242
|
+
async function extractArchive(archive, destination, spec, { spawnImpl = spawn, timeout = LIMITS.timeout } = {}) {
|
|
217
243
|
let input = archive;
|
|
218
244
|
const windows = spec.asset.endsWith('.zip');
|
|
219
245
|
if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
|
|
220
246
|
const entries = windows ? await zipEntries(input) : await tarEntries(input);
|
|
221
247
|
validateEntries(entries, windows);
|
|
222
248
|
await new Promise((resolve, reject) => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
let child, timer, closeTimer, errorBytes = 0, captured = 0, stopped, processCode;
|
|
250
|
+
let started = false, settled = false;
|
|
251
|
+
const chunks = [];
|
|
252
|
+
const details = (code, signal, spawnCode) => ({ code, signal, spawnCode,
|
|
253
|
+
stderr: Buffer.concat(chunks).toString('utf8'), truncated: errorBytes > 4096 });
|
|
254
|
+
const finish = (error, unknown = false) => {
|
|
255
|
+
if (settled) return;
|
|
256
|
+
settled = true;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
clearTimeout(closeTimer);
|
|
259
|
+
if (child) {
|
|
260
|
+
child.removeListener('spawn', onSpawn);
|
|
261
|
+
child.removeListener('error', onError);
|
|
262
|
+
child.removeListener('close', onClose);
|
|
263
|
+
child.stderr.removeListener('data', onData);
|
|
264
|
+
// A late kill error must remain handled without retaining this attempt's
|
|
265
|
+
// callbacks or changing its already settled cleanup authority.
|
|
266
|
+
child.on('error', () => {});
|
|
267
|
+
if (unknown) { child.unref?.(); child.stderr.destroy(); }
|
|
268
|
+
child = undefined;
|
|
269
|
+
}
|
|
270
|
+
chunks.length = 0;
|
|
271
|
+
if (error) reject(error);
|
|
272
|
+
else resolve();
|
|
273
|
+
};
|
|
274
|
+
const spawnFailure = error => {
|
|
275
|
+
const failure = extractionError(error.code === 'ENOENT'
|
|
276
|
+
? 'Native tar is unavailable; install the operating system tar command'
|
|
277
|
+
: 'Native tar could not start', destination, details(null, null, error.code));
|
|
278
|
+
finish(failure);
|
|
279
|
+
};
|
|
280
|
+
const stop = reason => {
|
|
281
|
+
if (settled || stopped) return;
|
|
282
|
+
stopped = reason;
|
|
283
|
+
// A process blocked in filesystem I/O may not close even after SIGKILL.
|
|
284
|
+
// Arm the grace before kill: it can throw, return false, or emit events
|
|
285
|
+
// synchronously. Only close proves the destination can be cleaned up.
|
|
286
|
+
closeTimer = setTimeout(() => {
|
|
287
|
+
const error = extractionError(`${stopped}; tar did not close after termination; staging retained for safety`,
|
|
288
|
+
destination, details(null, null, processCode));
|
|
289
|
+
error.extractionMayBeRunning = true;
|
|
290
|
+
finish(error, true);
|
|
291
|
+
}, 1000);
|
|
292
|
+
try { child.kill('SIGKILL'); }
|
|
293
|
+
catch (error) { if (!settled) processCode ||= error.code; }
|
|
294
|
+
};
|
|
295
|
+
const onSpawn = () => { if (!settled) started = true; };
|
|
296
|
+
const onError = error => {
|
|
297
|
+
if (settled) return;
|
|
298
|
+
// Node leaves pid undefined when spawn is refused. A pid, a spawn event,
|
|
299
|
+
// or an attempted kill makes an error insufficient proof of termination.
|
|
300
|
+
if (!started && child.pid === undefined && !stopped) { spawnFailure(error); return; }
|
|
301
|
+
processCode ||= error.code;
|
|
302
|
+
stop('Native tar process failed');
|
|
303
|
+
};
|
|
304
|
+
const onData = chunk => {
|
|
305
|
+
if (settled) return;
|
|
306
|
+
started = true;
|
|
307
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
308
|
+
errorBytes += bytes.length;
|
|
309
|
+
if (captured < 65536) {
|
|
310
|
+
const part = Buffer.from(bytes.subarray(0, 65536 - captured));
|
|
311
|
+
chunks.push(part); captured += part.length;
|
|
312
|
+
}
|
|
313
|
+
if (errorBytes > 65536) stop('Native tar stderr exceeded its diagnostic limit');
|
|
314
|
+
};
|
|
315
|
+
// Wait for close after killing tar so staging cleanup cannot race extraction.
|
|
316
|
+
const onClose = (code, signal) => {
|
|
317
|
+
if (settled) return;
|
|
318
|
+
if (!stopped && code === 0 && !signal) finish();
|
|
319
|
+
else finish(extractionError(stopped || 'Native tar rejected the release archive', destination, details(code, signal, processCode)));
|
|
320
|
+
};
|
|
321
|
+
try {
|
|
322
|
+
child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
|
|
323
|
+
{ shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
|
|
324
|
+
} catch (error) { spawnFailure(error); return; }
|
|
325
|
+
started = child.pid !== undefined && child.pid !== null;
|
|
326
|
+
timer = setTimeout(() => stop('Native tar extraction timed out'), Math.min(timeout, LIMITS.timeout));
|
|
327
|
+
child.once('spawn', onSpawn);
|
|
328
|
+
child.on('error', onError);
|
|
329
|
+
child.once('close', onClose);
|
|
330
|
+
child.stderr.on('data', onData);
|
|
230
331
|
});
|
|
231
332
|
return entries;
|
|
232
333
|
}
|
|
233
|
-
module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, TOP_DIRECTORIES, BUNDLE };
|
|
334
|
+
module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, diagnosticText, TOP_DIRECTORIES, BUNDLE };
|
package/lib/cli.js
CHANGED
|
@@ -4,12 +4,16 @@ const { fail, readSmall } = require('./common');
|
|
|
4
4
|
const { locations, releaseSpec, ensureInstalled, stageReporter, packageVersion } = require('./install');
|
|
5
5
|
const { openSuite, FRONTENDS } = require('./runtime');
|
|
6
6
|
const { bindAgent, executeAgent, agentCapabilities, WRITE_COMMANDS, MAX_PACKET_BYTES } = require('./agent');
|
|
7
|
+
const transfer = require('./transfer');
|
|
7
8
|
|
|
8
9
|
const HELP = `WorkBite ${packageVersion} - Work Stack organizes the work. StackBite gets it moving.
|
|
9
10
|
|
|
10
11
|
Usage:
|
|
11
12
|
workbite install download, verify and install the pinned native suite
|
|
12
13
|
workbite check verify the installed suite (no download, no processes)
|
|
14
|
+
workbite status [--json] inspect startup records without starting anything
|
|
15
|
+
workbite doctor [--json] explain startup evidence and its limits (read-only)
|
|
16
|
+
workbite recover --dry-run [--json] print a recovery plan only; never changes state
|
|
13
17
|
workbite open --workspace ABS [--init] [--no-ui] [--frontend typescript|rust]
|
|
14
18
|
start or reuse this workspace's Work Stack owner and the
|
|
15
19
|
StackBite backend, then run the Work Stack terminal UI
|
|
@@ -18,7 +22,8 @@ Usage:
|
|
|
18
22
|
workbite agent [--workspace ABS --workspace-uid UUID] COMMAND [--input-file FILE | --stdin] [flags]
|
|
19
23
|
curated Work Stack agent commands against the running,
|
|
20
24
|
WorkBite-owned owner (never starts one): capabilities, status,
|
|
21
|
-
tasks list|create|update, context, checkpoint, operation-status
|
|
25
|
+
tasks list|create|update, context, checkpoint, operation-status,
|
|
26
|
+
export, import, import-status, import-rollback
|
|
22
27
|
workbite mcp [--workspace ABS --workspace-uid UUID]
|
|
23
28
|
WorkBite MCP server on stdio for the same agent commands
|
|
24
29
|
workbite --version | --help
|
|
@@ -29,10 +34,22 @@ agent:
|
|
|
29
34
|
and a packet from --input-file FILE or --stdin (UTF-8, at most 32768 bytes). An uncertain write
|
|
30
35
|
is never replayed; check it with agent operation-status --intent-id ID.
|
|
31
36
|
|
|
37
|
+
Task transfer (archive at most 1 MiB; uses the running owner, never starts one):
|
|
38
|
+
workbite agent export --output tasks.json [--include-history] [--redact]
|
|
39
|
+
workbite agent import --input-file tasks.json --mode new --dry-run
|
|
40
|
+
workbite agent --workspace ABS --workspace-uid UUID import --input-file tasks.json --mode new
|
|
41
|
+
--intent-id ID --plan-digest sha256:HEX --expected-snapshot-digest sha256:HEX [--id-map map.json]
|
|
42
|
+
workbite agent import-status --intent-id ID
|
|
43
|
+
workbite agent --workspace ABS --workspace-uid UUID import-rollback --intent-id ID --import-id ID
|
|
44
|
+
--expected-snapshot-digest sha256:HEX
|
|
45
|
+
merge is also supported. --preserve-ids is explicit. Output files must be new local files.
|
|
46
|
+
Import unknowns require import-status with the SAME intent; no automatic replay or Task status changes.
|
|
47
|
+
Full workspace relocation uses official backup/restore.
|
|
48
|
+
|
|
32
49
|
open:
|
|
33
50
|
--workspace ABS absolute workspace folder; always wins over the remembered one
|
|
34
51
|
--init create a new Work Stack workspace in a new or empty folder (needs --workspace)
|
|
35
|
-
--no-ui start or reuse the backends, print one JSON summary and exit
|
|
52
|
+
--no-ui start or reuse the backends, print one JSON summary and exit; does not prove UI works
|
|
36
53
|
--frontend NAME typescript (default): the familiar Work Stack UI, with same-window 9 Execution
|
|
37
54
|
on execution bundles; rust: the explicit alternative unified UI (execution
|
|
38
55
|
bundles only). Chosen per open, never saved.
|
|
@@ -62,10 +79,14 @@ const AGENT_FLAGS = Object.freeze({
|
|
|
62
79
|
context: ['--task', '--view', '--format', '--source', '--fingerprint', '--if-fingerprint'],
|
|
63
80
|
checkpoint: ['--intent-id'],
|
|
64
81
|
'operation-status': ['--intent-id'],
|
|
82
|
+
export: ['--include-history', '--redact'],
|
|
83
|
+
import: ['--mode', '--dry-run', '--preserve-ids', '--intent-id', '--plan-digest', '--expected-snapshot-digest'],
|
|
84
|
+
'import-status': ['--intent-id'],
|
|
85
|
+
'import-rollback': ['--intent-id', '--import-id', '--expected-snapshot-digest'],
|
|
65
86
|
});
|
|
66
|
-
const FLAGS_WITHOUT_VALUE = new Set(['--workspace-revision', '--fingerprint']);
|
|
87
|
+
const FLAGS_WITHOUT_VALUE = new Set(['--workspace-revision', '--fingerprint', '--include-history', '--redact', '--dry-run', '--preserve-ids']);
|
|
67
88
|
const REPEATABLE = new Set(['--source']);
|
|
68
|
-
const PACKET_COMMANDS = new Set(['tasks create', 'tasks update', 'checkpoint']);
|
|
89
|
+
const PACKET_COMMANDS = new Set(['tasks create', 'tasks update', 'checkpoint', 'import']);
|
|
69
90
|
|
|
70
91
|
function parseBinding(options, argument, next) {
|
|
71
92
|
if (argument === '--workspace' && options.workspace === undefined && next !== undefined && !next.startsWith('--')) {
|
|
@@ -101,6 +122,11 @@ function parseAgent(rest) {
|
|
|
101
122
|
for (let index = 0; index < rest.length; index++) {
|
|
102
123
|
const argument = rest[index], next = rest[index + 1];
|
|
103
124
|
if (parseBinding(options, argument, next)) { index++; continue; }
|
|
125
|
+
if (((argument === '--output' && name === 'export' && options.output === undefined)
|
|
126
|
+
|| (argument === '--id-map' && name === 'import' && options.idMap === undefined))
|
|
127
|
+
&& next !== undefined && !next.startsWith('--')) {
|
|
128
|
+
options[argument === '--output' ? 'output' : 'idMap'] = next; index++; continue;
|
|
129
|
+
}
|
|
104
130
|
if (argument === '--input-file' && options.inputFile === undefined && !options.stdin && next !== undefined
|
|
105
131
|
&& !next.startsWith('--')) { options.inputFile = next; index++; continue; }
|
|
106
132
|
if (argument === '--stdin' && !options.stdin && options.inputFile === undefined) { options.stdin = true; continue; }
|
|
@@ -118,7 +144,23 @@ function parseAgent(rest) {
|
|
|
118
144
|
fail(PACKET_COMMANDS.has(name) ? `agent ${name} needs a packet from --input-file FILE or --stdin`
|
|
119
145
|
: `agent ${name} does not take --input-file or --stdin`);
|
|
120
146
|
}
|
|
121
|
-
|
|
147
|
+
const value = flag => options.native[options.native.indexOf(flag) + 1];
|
|
148
|
+
const has = flag => options.native.includes(flag);
|
|
149
|
+
const dryRun = name === 'import' && has('--dry-run');
|
|
150
|
+
if (name === 'import') {
|
|
151
|
+
if (options.inputFile === undefined) fail('agent import requires --input-file FILE');
|
|
152
|
+
if (!has('--mode') || !['new', 'merge'].includes(value('--mode'))) fail('--mode must be new or merge');
|
|
153
|
+
const proofs = ['--intent-id', '--plan-digest', '--expected-snapshot-digest'];
|
|
154
|
+
if (dryRun && (proofs.some(has) || options.idMap !== undefined)) fail('Import preview does not accept write identity, proofs or --id-map');
|
|
155
|
+
if (!dryRun && proofs.some(flag => !has(flag))) fail('Import apply requires --intent-id, --plan-digest and --expected-snapshot-digest from preview');
|
|
156
|
+
}
|
|
157
|
+
if (['import-status', 'import-rollback'].includes(name) && !has('--intent-id')) fail(`agent ${name} requires --intent-id`);
|
|
158
|
+
if (name === 'import-rollback' && (!has('--import-id') || !has('--expected-snapshot-digest'))) fail('Import rollback requires --import-id and --expected-snapshot-digest');
|
|
159
|
+
for (const proof of ['--plan-digest', '--expected-snapshot-digest']) {
|
|
160
|
+
if (has(proof) && !transfer.DIGEST.test(value(proof))) fail(`${proof} must be sha256: followed by 64 lowercase hex digits`);
|
|
161
|
+
}
|
|
162
|
+
if (has('--intent-id') && name.startsWith('import') && !/^[A-Za-z0-9._:-]{8,128}$/.test(value('--intent-id'))) fail('Invalid transfer --intent-id');
|
|
163
|
+
if (WRITE_COMMANDS.includes(name) && !dryRun && options.workspace === undefined) {
|
|
122
164
|
fail(`agent ${name} changes Work Stack; name the workspace with --workspace ABS --workspace-uid UUID`);
|
|
123
165
|
}
|
|
124
166
|
if (packet) options.native.push('--stdin');
|
|
@@ -142,6 +184,16 @@ function parseArguments(argv) {
|
|
|
142
184
|
// Bare `workbite` (or only open options) means `workbite open`.
|
|
143
185
|
const [command, ...rest] = argv.length && !argv[0].startsWith('--') ? argv : ['open', ...argv];
|
|
144
186
|
if ((command === 'install' || command === 'check') && !rest.length) return { command };
|
|
187
|
+
if (['status', 'doctor', 'recover'].includes(command)) {
|
|
188
|
+
const options = { command, json: false, dryRun: false };
|
|
189
|
+
for (const argument of rest) {
|
|
190
|
+
if (argument === '--json' && !options.json) options.json = true;
|
|
191
|
+
else if (command === 'recover' && argument === '--dry-run' && !options.dryRun) options.dryRun = true;
|
|
192
|
+
else fail(`Unsupported or repeated ${command} option. See workbite --help.`);
|
|
193
|
+
}
|
|
194
|
+
if (command === 'recover' && !options.dryRun) fail('recover requires --dry-run; automatic repair is not supported.');
|
|
195
|
+
return options;
|
|
196
|
+
}
|
|
145
197
|
if (command === 'agent') return parseAgent([...rest]);
|
|
146
198
|
if (command === 'mcp') return parseMcp(rest);
|
|
147
199
|
if (command === 'open') {
|
|
@@ -202,10 +254,26 @@ async function runAgent(parsed, { installed, stateRoot, environment, commands, t
|
|
|
202
254
|
} else {
|
|
203
255
|
binding = await bindAgent({ installed, stateRoot, environment, workspace: parsed.workspace,
|
|
204
256
|
workspaceUid: parsed.workspaceUid, commands, timeouts });
|
|
205
|
-
|
|
257
|
+
if (parsed.output !== undefined) await transfer.outputPath(parsed.output);
|
|
258
|
+
if (parsed.idMap !== undefined) await transfer.outputPath(parsed.idMap);
|
|
259
|
+
const packet = parsed.name === 'import' ? await transfer.readArchive(parsed.inputFile) : await readPacket(parsed, stdin || process.stdin);
|
|
206
260
|
result = await executeAgent(binding, parsed.native, { stdin: packet });
|
|
207
261
|
}
|
|
208
262
|
let output = result.stdout;
|
|
263
|
+
if (result.code === 0 && (parsed.output !== undefined || parsed.idMap !== undefined)) {
|
|
264
|
+
try {
|
|
265
|
+
const intentIndex = parsed.native.indexOf('--intent-id');
|
|
266
|
+
const data = transfer.resultData(result, parsed.name, binding.workspaceUid,
|
|
267
|
+
intentIndex === -1 ? undefined : parsed.native[intentIndex + 1]);
|
|
268
|
+
if (parsed.output !== undefined) await transfer.writePrivate(parsed.output, transfer.archive(data, binding.workspaceUid));
|
|
269
|
+
if (parsed.idMap !== undefined) await transfer.writePrivate(parsed.idMap, transfer.idMap(data.id_map));
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// Apply may already be committed: preserve its receipt and never replay for a local file failure.
|
|
272
|
+
if (output) stdout.write(output);
|
|
273
|
+
stderr.write(`error: transfer file output failed: ${error.message}. Native operation was not replayed; retain this response and use import-status for any write.\n`);
|
|
274
|
+
return { code: 1, signal: null };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
209
277
|
if (parsed.name === 'status' && result.code === 0) {
|
|
210
278
|
try {
|
|
211
279
|
const document = JSON.parse(output);
|
|
@@ -228,6 +296,9 @@ async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
228
296
|
if (parsed.command === 'version') { stdout.write(`workbite ${packageVersion}\n`); return { code: 0, signal: null }; }
|
|
229
297
|
const environment = options.environment || process.env;
|
|
230
298
|
const platform = options.platform || process.platform;
|
|
299
|
+
if (['status', 'doctor', 'recover'].includes(parsed.command)) {
|
|
300
|
+
return require('./diagnostics').runDiagnostics(parsed, { ...options, environment, platform, stdout });
|
|
301
|
+
}
|
|
231
302
|
const spec = releaseSpec(options.manifest || require('../release.json'), platform, options.arch || process.arch, options);
|
|
232
303
|
const where = locations(environment, platform);
|
|
233
304
|
// agent and mcp only use an already installed, verified suite: nothing is downloaded or started.
|
|
@@ -246,7 +317,7 @@ async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
246
317
|
return { code: 0, signal: null };
|
|
247
318
|
}
|
|
248
319
|
return openSuite({ installed, commands: options.commands, workspace: parsed.workspace, init: parsed.init, ui: parsed.ui,
|
|
249
|
-
frontend: parsed.frontend, stateRoot, environment, spawnImpl: options.spawnImpl, execImpl: options.execImpl, stdout, timeouts: options.timeouts });
|
|
320
|
+
frontend: parsed.frontend, stateRoot, environment, spawnImpl: options.spawnImpl, execImpl: options.execImpl, stdout, stderr, timeouts: options.timeouts });
|
|
250
321
|
}
|
|
251
322
|
|
|
252
323
|
module.exports = { main, parseArguments, HELP };
|