@yeaft/webchat-agent 1.0.413 → 1.0.415
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/browser-runtime/browser-install.js +497 -0
- package/browser-runtime/cli.js +88 -0
- package/browser-runtime/config.js +116 -0
- package/browser-runtime/errors.js +8 -0
- package/browser-runtime/extension/manifest.json +18 -0
- package/browser-runtime/extension/offscreen.html +5 -0
- package/browser-runtime/extension/offscreen.js +101 -0
- package/browser-runtime/extension/popup.html +5 -0
- package/browser-runtime/extension/popup.js +1 -0
- package/browser-runtime/extension/service-worker.js +48 -0
- package/browser-runtime/extension.js +45 -0
- package/browser-runtime/index.js +5 -0
- package/browser-runtime/probe.js +427 -0
- package/browser-runtime/protocol.js +71 -0
- package/browser-runtime/service.js +132 -0
- package/browser-runtime/windows-version-job.ps1 +233 -0
- package/browser-runtime/windows-version-worker.js +75 -0
- package/browser-runtime/windows-version.js +85 -0
- package/cli.js +24 -7
- package/connection/index.js +12 -0
- package/context.js +1 -0
- package/index.js +19 -2
- package/llm-config-cli.js +24 -21
- package/local-runtime/server/client-protocol.js +14 -0
- package/local-runtime/server/context.js +3 -2
- package/local-runtime/server/handlers/agent-file-terminal.js +185 -115
- package/local-runtime/server/handlers/agent-output.js +3 -0
- package/local-runtime/server/handlers/client-misc.js +21 -4
- package/local-runtime/server/handlers/client-workbench.js +222 -41
- package/local-runtime/server/workbench-correlation.js +184 -0
- package/local-runtime/server/workbench-route.js +180 -0
- package/local-runtime/server/ws-agent.js +4 -0
- package/local-runtime/server/ws-client.js +25 -3
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +191 -135
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +5 -1
- package/service/config.js +23 -2
- package/service/index.js +1 -0
- package/service/linux.js +3 -2
- package/terminal.js +167 -30
- package/workbench/file-ops.js +21 -20
- package/workbench/file-search.js +4 -3
- package/workbench/git-ops.js +23 -22
- package/workbench/request-routing.js +16 -0
- package/yeaft/cli.js +57 -1
- package/yeaft/config-api.js +138 -192
- package/yeaft/config-store.js +192 -0
- package/yeaft/config.js +3 -0
- package/yeaft/init.js +20 -7
- package/yeaft/sessions/feature-flag.js +15 -33
- package/yeaft/sessions/session-manifest.js +114 -10
- package/yeaft/stdio-protocol.js +57 -0
- package/yeaft/storage/atomic.js +43 -17
- package/yeaft/tools/process-runner.js +86 -13
package/yeaft/init.js
CHANGED
|
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, access
|
|
|
9
9
|
import { join, dirname } from 'path';
|
|
10
10
|
import { homedir } from 'os';
|
|
11
11
|
import { createHash } from 'crypto';
|
|
12
|
+
import { mutateAgentConfig } from './config-store.js';
|
|
12
13
|
// NOTE: migrateSessions runs at the end of initYeaftDir(). It collapses
|
|
13
14
|
// legacy groups/ + chats/ + memory/{group,chat}/ into the unified sessions/
|
|
14
15
|
// layout AND rewrites pre-rename per-message frontmatter (groupId → sessionId).
|
|
@@ -31,9 +32,9 @@ export function isPermissionError(err) {
|
|
|
31
32
|
* @param {string} content
|
|
32
33
|
* @param {string[]} warnings — array to push warning messages into
|
|
33
34
|
*/
|
|
34
|
-
function safeWriteFile(filePath, content, warnings) {
|
|
35
|
+
function safeWriteFile(filePath, content, warnings, mode = 0o644) {
|
|
35
36
|
try {
|
|
36
|
-
writeFileSync(filePath, content, { encoding: 'utf8', mode
|
|
37
|
+
writeFileSync(filePath, content, { encoding: 'utf8', mode });
|
|
37
38
|
} catch (err) {
|
|
38
39
|
if (isPermissionError(err)) {
|
|
39
40
|
warnings.push(`Cannot write ${filePath}: ${err.code}`);
|
|
@@ -49,9 +50,9 @@ function safeWriteFile(filePath, content, warnings) {
|
|
|
49
50
|
* @param {string[]} warnings — array to push warning messages into
|
|
50
51
|
* @returns {boolean} — true if directory exists (created or already existed)
|
|
51
52
|
*/
|
|
52
|
-
function safeMkdir(dirPath, warnings) {
|
|
53
|
+
function safeMkdir(dirPath, warnings, mode = 0o755) {
|
|
53
54
|
try {
|
|
54
|
-
mkdirSync(dirPath, { recursive: true, mode
|
|
55
|
+
mkdirSync(dirPath, { recursive: true, mode });
|
|
55
56
|
return true;
|
|
56
57
|
} catch (err) {
|
|
57
58
|
if (isPermissionError(err)) {
|
|
@@ -168,7 +169,7 @@ export function initYeaftDir(dir) {
|
|
|
168
169
|
|
|
169
170
|
// Ensure root exists
|
|
170
171
|
if (!existsSync(root)) {
|
|
171
|
-
if (safeMkdir(root, warnings)) {
|
|
172
|
+
if (safeMkdir(root, warnings, 0o700)) {
|
|
172
173
|
created.push(root);
|
|
173
174
|
}
|
|
174
175
|
}
|
|
@@ -194,8 +195,20 @@ export function initYeaftDir(dir) {
|
|
|
194
195
|
// config.json — default configuration (user edits this directly)
|
|
195
196
|
const configJsonPath = join(root, 'config.json');
|
|
196
197
|
if (!existsSync(configJsonPath)) {
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
let seeded = false;
|
|
199
|
+
try {
|
|
200
|
+
const defaults = JSON.parse(DEFAULT_CONFIG_JSON);
|
|
201
|
+
mutateAgentConfig(root, (current, state) => {
|
|
202
|
+
if (!state.exists) {
|
|
203
|
+
Object.assign(current, defaults);
|
|
204
|
+
seeded = true;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
if (seeded) created.push(configJsonPath);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (isPermissionError(err)) warnings.push(`Cannot write ${configJsonPath}: ${err.code}`);
|
|
210
|
+
else throw err;
|
|
211
|
+
}
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
const memoryPath = join(root, 'memory', 'MEMORY.md');
|
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync } from 'fs';
|
|
14
14
|
import { join } from 'path';
|
|
15
|
-
import {
|
|
16
|
-
import { writeAtomic } from '../storage/index.js';
|
|
15
|
+
import { mutateAgentConfig } from '../config-store.js';
|
|
17
16
|
|
|
18
17
|
const CONFIG_FILE = 'config.json';
|
|
19
18
|
const FLAG_PATH = ['yeaft', 'multiVp', 'enabled'];
|
|
@@ -34,20 +33,6 @@ function readConfig(yeaftDir) {
|
|
|
34
33
|
* remain tolerant because the flag is optional, but no mutation may replace a
|
|
35
34
|
* malformed root or a Plugin policy that the runtime must keep fail-closed.
|
|
36
35
|
*/
|
|
37
|
-
function readConfigForWrite(yeaftDir) {
|
|
38
|
-
const path = join(yeaftDir, CONFIG_FILE);
|
|
39
|
-
if (!existsSync(path)) return {};
|
|
40
|
-
const config = JSON.parse(readFileSync(path, 'utf8'));
|
|
41
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)
|
|
42
|
-
|| Object.getPrototypeOf(config) !== Object.prototype) {
|
|
43
|
-
throw new Error('config.json must contain an object');
|
|
44
|
-
}
|
|
45
|
-
if (Object.prototype.hasOwnProperty.call(config, 'plugins')) {
|
|
46
|
-
normalizePluginConfig(config.plugins);
|
|
47
|
-
}
|
|
48
|
-
return config;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
36
|
export function isMultiVpEnabled(yeaftDir) {
|
|
52
37
|
const cfg = readConfig(yeaftDir);
|
|
53
38
|
let cur = cfg;
|
|
@@ -59,24 +44,21 @@ export function isMultiVpEnabled(yeaftDir) {
|
|
|
59
44
|
}
|
|
60
45
|
|
|
61
46
|
export function setMultiVpEnabled(yeaftDir, enabled) {
|
|
62
|
-
let cfg;
|
|
63
|
-
try {
|
|
64
|
-
cfg = readConfigForWrite(yeaftDir);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
return { error: `Failed to read config.json: ${err?.message || err}` };
|
|
67
|
-
}
|
|
68
|
-
let cur = cfg;
|
|
69
|
-
for (let i = 0; i < FLAG_PATH.length - 1; i++) {
|
|
70
|
-
const seg = FLAG_PATH[i];
|
|
71
|
-
if (!cur[seg] || typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) cur[seg] = {};
|
|
72
|
-
cur = cur[seg];
|
|
73
|
-
}
|
|
74
47
|
const nextValue = Boolean(enabled);
|
|
75
|
-
cur[FLAG_PATH[FLAG_PATH.length - 1]] = nextValue;
|
|
76
48
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
49
|
+
return mutateAgentConfig(yeaftDir, config => {
|
|
50
|
+
let current = config;
|
|
51
|
+
for (let index = 0; index < FLAG_PATH.length - 1; index += 1) {
|
|
52
|
+
const segment = FLAG_PATH[index];
|
|
53
|
+
if (!current[segment] || typeof current[segment] !== 'object' || Array.isArray(current[segment])) {
|
|
54
|
+
current[segment] = {};
|
|
55
|
+
}
|
|
56
|
+
current = current[segment];
|
|
57
|
+
}
|
|
58
|
+
current[FLAG_PATH.at(-1)] = nextValue;
|
|
59
|
+
return { enabled: nextValue };
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { error: `Failed to read config.json or persist update: ${error?.message || error}` };
|
|
80
63
|
}
|
|
81
|
-
return { enabled: nextValue };
|
|
82
64
|
}
|
|
@@ -15,18 +15,25 @@
|
|
|
15
15
|
import {
|
|
16
16
|
cpSync,
|
|
17
17
|
existsSync,
|
|
18
|
+
linkSync,
|
|
18
19
|
mkdirSync,
|
|
19
20
|
readFileSync,
|
|
20
21
|
readdirSync,
|
|
21
22
|
rmSync,
|
|
22
23
|
statSync,
|
|
24
|
+
unlinkSync,
|
|
23
25
|
writeFileSync,
|
|
24
26
|
} from 'fs';
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
25
28
|
import { join } from 'path';
|
|
26
29
|
import { loadSessionMeta } from './session-store.js';
|
|
30
|
+
import { writeAtomic } from '../storage/atomic.js';
|
|
27
31
|
|
|
28
32
|
export const SESSIONS_MANIFEST_FILE = 'sessions-manifest.json';
|
|
29
33
|
const MANIFEST_VERSION = 1;
|
|
34
|
+
const MANIFEST_LOCK_FILE = 'sessions-manifest.lock';
|
|
35
|
+
const MANIFEST_LOCK_WAIT_MS = 5_000;
|
|
36
|
+
const lockWaitArray = new Int32Array(new SharedArrayBuffer(4));
|
|
30
37
|
|
|
31
38
|
export function sessionManifestPath(yeaftDir) {
|
|
32
39
|
return join(yeaftDir, SESSIONS_MANIFEST_FILE);
|
|
@@ -62,6 +69,10 @@ export function loadSessionsManifest(yeaftDir) {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export function writeSessionsManifest(yeaftDir, sessions) {
|
|
72
|
+
return withManifestLock(yeaftDir, () => writeSessionsManifestUnlocked(yeaftDir, sessions));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeSessionsManifestUnlocked(yeaftDir, sessions) {
|
|
65
76
|
if (!yeaftDir) throw new Error('yeaftDir required');
|
|
66
77
|
mkdirSync(yeaftDir, { recursive: true });
|
|
67
78
|
const manifest = {
|
|
@@ -69,7 +80,7 @@ export function writeSessionsManifest(yeaftDir, sessions) {
|
|
|
69
80
|
generatedAt: new Date().toISOString(),
|
|
70
81
|
sessions: dedupeSessions(sessions).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))),
|
|
71
82
|
};
|
|
72
|
-
|
|
83
|
+
writeAtomic(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
73
84
|
return manifest;
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -171,21 +182,114 @@ export function ensureSessionsManifest(yeaftDir, options) {
|
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
184
|
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
return withManifestLock(yeaftDir, () => {
|
|
186
|
+
const current = loadSessionsManifest(yeaftDir);
|
|
187
|
+
const manifest = writeSessionsManifestUnlocked(yeaftDir, [
|
|
188
|
+
...(current?.sessions || []),
|
|
189
|
+
...buildManifestFromLocalSessions(yeaftDir, root),
|
|
190
|
+
]);
|
|
191
|
+
return {
|
|
192
|
+
created: !current,
|
|
193
|
+
migrated,
|
|
194
|
+
skipped,
|
|
195
|
+
migratedIds,
|
|
196
|
+
skippedIds,
|
|
197
|
+
manifest,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
export function addOrUpdateManifestSession(yeaftDir, meta, dir) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
return withManifestLock(yeaftDir, () => {
|
|
204
|
+
const current = loadSessionsManifest(yeaftDir);
|
|
205
|
+
const recovered = current?.sessions
|
|
206
|
+
|| buildManifestFromLocalSessions(yeaftDir, join(yeaftDir, 'sessions'));
|
|
207
|
+
const rows = recovered.filter(row => row.id !== meta.id);
|
|
208
|
+
rows.push(manifestRowFromMeta(meta, dir));
|
|
209
|
+
return writeSessionsManifestUnlocked(yeaftDir, rows);
|
|
210
|
+
});
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
export function removeManifestSession(yeaftDir, sessionId) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
214
|
+
return withManifestLock(yeaftDir, () => {
|
|
215
|
+
const current = loadSessionsManifest(yeaftDir);
|
|
216
|
+
if (!current) return null;
|
|
217
|
+
return writeSessionsManifestUnlocked(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function withSessionManifestLock(yeaftDir, operation) {
|
|
222
|
+
if (!yeaftDir) throw new Error('yeaftDir required');
|
|
223
|
+
mkdirSync(yeaftDir, { recursive: true });
|
|
224
|
+
const lockFile = join(yeaftDir, MANIFEST_LOCK_FILE);
|
|
225
|
+
const deadline = Date.now() + MANIFEST_LOCK_WAIT_MS;
|
|
226
|
+
const token = randomUUID();
|
|
227
|
+
const ownerFile = `${lockFile}.owner.${process.pid}.${token}`;
|
|
228
|
+
const owner = { pid: process.pid, token, ownerFile };
|
|
229
|
+
writeFileSync(ownerFile, `${JSON.stringify(owner)}\n`, { flag: 'wx' });
|
|
230
|
+
for (;;) {
|
|
231
|
+
try {
|
|
232
|
+
linkSync(ownerFile, lockFile);
|
|
233
|
+
break;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error?.code !== 'EEXIST') {
|
|
236
|
+
try { unlinkSync(ownerFile); } catch {}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
reapDeadManifestLock(lockFile);
|
|
240
|
+
if (Date.now() >= deadline) {
|
|
241
|
+
try { unlinkSync(ownerFile); } catch {}
|
|
242
|
+
throw new Error('Timed out waiting for Session manifest lock');
|
|
243
|
+
}
|
|
244
|
+
Atomics.wait(lockWaitArray, 0, 0, 25);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return operation();
|
|
249
|
+
} finally {
|
|
250
|
+
try {
|
|
251
|
+
const current = JSON.parse(readFileSync(lockFile, 'utf8'));
|
|
252
|
+
if (current.token === owner.token) unlinkSync(lockFile);
|
|
253
|
+
} catch {
|
|
254
|
+
// A missing lock means another process already recovered this owner.
|
|
255
|
+
}
|
|
256
|
+
try { unlinkSync(ownerFile); } catch {}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function withManifestLock(yeaftDir, operation) {
|
|
261
|
+
return withSessionManifestLock(yeaftDir, operation);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function reapDeadManifestLock(lockFile) {
|
|
265
|
+
let observed;
|
|
266
|
+
try {
|
|
267
|
+
observed = JSON.parse(readFileSync(lockFile, 'utf8'));
|
|
268
|
+
} catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!Number.isInteger(observed.pid) || typeof observed.token !== 'string') return;
|
|
272
|
+
try {
|
|
273
|
+
process.kill(observed.pid, 0);
|
|
274
|
+
return;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error?.code === 'EPERM') return;
|
|
277
|
+
}
|
|
278
|
+
const claim = `${lockFile}.reap.${process.pid}.${randomUUID()}`;
|
|
279
|
+
try {
|
|
280
|
+
linkSync(lockFile, claim);
|
|
281
|
+
const claimed = JSON.parse(readFileSync(claim, 'utf8'));
|
|
282
|
+
if (claimed.token !== observed.token) return;
|
|
283
|
+
unlinkSync(lockFile);
|
|
284
|
+
const expectedOwnerFile = `${lockFile}.owner.${observed.pid}.${observed.token}`;
|
|
285
|
+
if (observed.ownerFile === expectedOwnerFile) {
|
|
286
|
+
try { unlinkSync(expectedOwnerFile); } catch {}
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// Another process either recovered or replaced the observed lock.
|
|
290
|
+
} finally {
|
|
291
|
+
try { unlinkSync(claim); } catch {}
|
|
292
|
+
}
|
|
189
293
|
}
|
|
190
294
|
|
|
191
295
|
function manifestRowFromMeta(meta, dir) {
|
package/yeaft/stdio-protocol.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isReservedVpId, validateVpId } from './sessions/ids.js';
|
|
3
4
|
|
|
4
5
|
const TERMINAL_STOP_REASONS = new Set([
|
|
5
6
|
'end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error', 'tool_handoff', 'plan_recorded',
|
|
@@ -78,6 +79,62 @@ export function normalizeStreamRoutingIntent(message) {
|
|
|
78
79
|
});
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Read the opt-in formal Session seed supplied by an integration's first
|
|
84
|
+
* stream-json prompt. Existing Sessions keep their persisted roster; this only
|
|
85
|
+
* establishes a canonical roster for a previously unknown session id.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeStreamSessionBootstrap(message) {
|
|
88
|
+
if (!message || typeof message !== 'object') return null;
|
|
89
|
+
const hasRoster = Object.hasOwn(message, 'roster');
|
|
90
|
+
const hasVps = Object.hasOwn(message, 'vps');
|
|
91
|
+
const hasDefault = Object.hasOwn(message, 'defaultVpId');
|
|
92
|
+
if (!hasRoster && !hasVps && !hasDefault) return null;
|
|
93
|
+
if (!hasRoster && !hasVps) {
|
|
94
|
+
throw new Error('stream-json defaultVpId requires roster or vps');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const readRoster = (key) => {
|
|
98
|
+
const value = message[key];
|
|
99
|
+
if (!Array.isArray(value)) throw new Error(`stream-json ${key} must be an array`);
|
|
100
|
+
return value.slice();
|
|
101
|
+
};
|
|
102
|
+
const roster = hasRoster ? readRoster('roster') : readRoster('vps');
|
|
103
|
+
if (hasRoster && hasVps) {
|
|
104
|
+
const vps = readRoster('vps');
|
|
105
|
+
if (vps.length !== roster.length || vps.some((vpId, index) => vpId !== roster[index])) {
|
|
106
|
+
throw new Error('stream-json roster and vps must contain the same VP ids in the same order');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
for (const vpId of roster) {
|
|
112
|
+
const verdict = validateVpId(vpId);
|
|
113
|
+
if (!verdict.ok || isReservedVpId(vpId)) {
|
|
114
|
+
throw new Error(`stream-json roster contains invalid VP id ${JSON.stringify(vpId)} (${verdict.reason || 'reserved'})`);
|
|
115
|
+
}
|
|
116
|
+
if (seen.has(vpId)) throw new Error(`stream-json roster contains duplicate VP id ${vpId}`);
|
|
117
|
+
seen.add(vpId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let defaultVpId = roster[0] || null;
|
|
121
|
+
if (hasDefault && message.defaultVpId != null) {
|
|
122
|
+
defaultVpId = message.defaultVpId;
|
|
123
|
+
const verdict = validateVpId(defaultVpId);
|
|
124
|
+
if (!verdict.ok || isReservedVpId(defaultVpId)) {
|
|
125
|
+
throw new Error(`stream-json defaultVpId is invalid (${verdict.reason || 'reserved'})`);
|
|
126
|
+
}
|
|
127
|
+
if (!seen.has(defaultVpId)) {
|
|
128
|
+
throw new Error(`stream-json defaultVpId ${defaultVpId} is not in roster`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
roster: Object.freeze(roster),
|
|
134
|
+
defaultVpId,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
81
138
|
export function createJsonlWriter(output = process.stdout) {
|
|
82
139
|
return event => { output.write(`${JSON.stringify(event)}\n`); };
|
|
83
140
|
}
|
package/yeaft/storage/atomic.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* the only debris; they are safe to delete on boot (see sweepTmp()).
|
|
10
10
|
*
|
|
11
11
|
* Implementation:
|
|
12
|
-
* 1.
|
|
13
|
-
* 2. fsync the
|
|
12
|
+
* 1. Exclusively create `path.tmp.<pid>.<counter>` and write through its fd.
|
|
13
|
+
* 2. fsync the same fd (force bytes to disk before rename).
|
|
14
14
|
* 3. rename(tmp, path) — POSIX-atomic on same filesystem.
|
|
15
15
|
* 4. fsync the parent dir (persist the rename itself).
|
|
16
16
|
*
|
|
@@ -31,36 +31,62 @@ import {
|
|
|
31
31
|
existsSync,
|
|
32
32
|
unlinkSync,
|
|
33
33
|
readdirSync,
|
|
34
|
+
lstatSync,
|
|
35
|
+
constants,
|
|
34
36
|
} from 'fs';
|
|
35
37
|
import { dirname, basename, join } from 'path';
|
|
36
38
|
|
|
37
39
|
let tmpCounter = 0;
|
|
38
40
|
|
|
41
|
+
export function nextAtomicTmpPathForTest(path) {
|
|
42
|
+
return `${path}.tmp.${process.pid}.${tmpCounter + 1}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function targetMode(path, requestedMode) {
|
|
46
|
+
try {
|
|
47
|
+
const stat = lstatSync(path);
|
|
48
|
+
if (!stat.isFile()) throw new Error(`Atomic write target is not a regular file: ${path}`);
|
|
49
|
+
const existingMode = stat.mode & 0o777;
|
|
50
|
+
return requestedMode == null ? existingMode : existingMode & requestedMode;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code === 'ENOENT') return requestedMode ?? 0o666;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/**
|
|
40
58
|
* Atomically write `data` (string | Buffer) to `path`.
|
|
41
59
|
* Throws on failure; never leaves `path` in a half-written state.
|
|
60
|
+
*
|
|
61
|
+
* When supplied, `mode` is the maximum permission set for the replacement and
|
|
62
|
+
* is still restricted by umask. Existing files preserve any tighter permissions
|
|
63
|
+
* but never retain bits outside that explicit maximum. Omitted mode preserves
|
|
64
|
+
* the historical behavior: existing modes survive, new files default to 0666.
|
|
42
65
|
*/
|
|
43
|
-
export function writeAtomic(path, data) {
|
|
66
|
+
export function writeAtomic(path, data, { mode = null } = {}) {
|
|
44
67
|
const dir = dirname(path);
|
|
45
68
|
const tmpPath = `${path}.tmp.${process.pid}.${++tmpCounter}`;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// fsync the tmp file so the bytes hit disk before we swap.
|
|
69
|
+
const fileMode = targetMode(path, mode);
|
|
70
|
+
let fd = null;
|
|
50
71
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
fd = openSync(
|
|
73
|
+
tmpPath,
|
|
74
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
75
|
+
fileMode,
|
|
76
|
+
);
|
|
77
|
+
writeFileSync(fd, data);
|
|
78
|
+
fsyncSync(fd);
|
|
79
|
+
closeSync(fd);
|
|
80
|
+
fd = null;
|
|
81
|
+
renameSync(tmpPath, path);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (fd !== null) {
|
|
84
|
+
try { closeSync(fd); } catch {}
|
|
85
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
56
86
|
}
|
|
57
|
-
|
|
58
|
-
// Best-effort; some filesystems / platforms don't support fsync on a file
|
|
59
|
-
// opened r+. The rename below is still the atomic boundary.
|
|
87
|
+
throw error;
|
|
60
88
|
}
|
|
61
89
|
|
|
62
|
-
renameSync(tmpPath, path);
|
|
63
|
-
|
|
64
90
|
// fsync the parent directory so the rename is durable.
|
|
65
91
|
// Windows: cannot fsync a directory; skip.
|
|
66
92
|
if (process.platform !== 'win32') {
|
|
@@ -71,18 +71,26 @@ function isSystemdScopeInactive(scope, spawnProcessSync) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function killProcessTree(
|
|
74
|
+
function killProcessTree(
|
|
75
|
+
proc,
|
|
76
|
+
signalName,
|
|
77
|
+
platform,
|
|
78
|
+
spawnProcessSync,
|
|
79
|
+
systemdScope,
|
|
80
|
+
commandTimeoutMs = 5000,
|
|
81
|
+
) {
|
|
75
82
|
if (!proc.pid) return false;
|
|
76
83
|
if (platform === 'win32') {
|
|
77
84
|
try {
|
|
78
85
|
const result = spawnProcessSync('taskkill', ['/pid', String(proc.pid), '/t', '/f'], {
|
|
79
86
|
stdio: 'ignore',
|
|
80
87
|
windowsHide: true,
|
|
81
|
-
timeout:
|
|
88
|
+
timeout: Math.max(1, commandTimeoutMs),
|
|
82
89
|
});
|
|
83
90
|
if (!result.error && result.status === 0) return true;
|
|
84
91
|
} catch {}
|
|
85
|
-
try {
|
|
92
|
+
try { proc.kill(signalName); } catch {}
|
|
93
|
+
return false;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
let signalled = signalSystemdScope(systemdScope, signalName, spawnProcessSync);
|
|
@@ -96,12 +104,22 @@ function killProcessTree(proc, signalName, platform, spawnProcessSync, systemdSc
|
|
|
96
104
|
return signalled;
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
function processGroupIsInactive(pid) {
|
|
108
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
109
|
+
try {
|
|
110
|
+
process.kill(-pid, 0);
|
|
111
|
+
return false;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return error?.code === 'ESRCH';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
/**
|
|
100
118
|
* Execute a binary directly without a shell and keep captured output bounded.
|
|
101
119
|
*
|
|
102
120
|
* @param {string} command
|
|
103
121
|
* @param {string[]} args
|
|
104
|
-
* @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, forceSettleMs?: number, requireExitConfirmation?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
|
|
122
|
+
* @param {{ cwd?: string, signal?: AbortSignal, timeoutMs?: number, maxBytes?: number, env?: NodeJS.ProcessEnv, preserveCarriageReturns?: boolean, killGraceMs?: number, gracefulTerminationDeadline?: number, terminationDeadline?: number, forceSettleMs?: number, treeKillTimeoutMs?: number, requireExitConfirmation?: boolean, requireProcessGroupExit?: boolean, systemdScope?: { unit: string, systemctlPath: string, env?: NodeJS.ProcessEnv } | null, onSettled?: (() => void) | null, platform?: NodeJS.Platform, spawnProcess?: typeof spawn, spawnProcessSync?: typeof spawnSync }} [options]
|
|
105
123
|
* @returns {Promise<{ code: number, stdout: string, stderr: string, truncated: boolean, timedOut: boolean, terminationError?: string }>}
|
|
106
124
|
*/
|
|
107
125
|
export function runProcess(command, args, options = {}) {
|
|
@@ -123,6 +141,17 @@ export function runProcess(command, args, options = {}) {
|
|
|
123
141
|
const forceSettleMs = Number.isFinite(options.forceSettleMs)
|
|
124
142
|
? Math.max(1, options.forceSettleMs)
|
|
125
143
|
: DEFAULT_FORCE_SETTLE_MS;
|
|
144
|
+
const treeKillTimeoutMs = Number.isFinite(options.treeKillTimeoutMs)
|
|
145
|
+
? Math.max(1, options.treeKillTimeoutMs)
|
|
146
|
+
: 5000;
|
|
147
|
+
const deadlineBudget = (maximum, deadline) => {
|
|
148
|
+
if (!Number.isFinite(deadline)) return maximum;
|
|
149
|
+
return Math.max(0, Math.min(maximum, deadline - Date.now()));
|
|
150
|
+
};
|
|
151
|
+
const terminationBudget = maximum => deadlineBudget(maximum, options.terminationDeadline);
|
|
152
|
+
const treeKillBudget = () => Number.isFinite(options.terminationDeadline)
|
|
153
|
+
? terminationBudget(treeKillTimeoutMs)
|
|
154
|
+
: treeKillTimeoutMs;
|
|
126
155
|
let proc;
|
|
127
156
|
try {
|
|
128
157
|
proc = spawnProcess(command, args, {
|
|
@@ -149,6 +178,8 @@ export function runProcess(command, args, options = {}) {
|
|
|
149
178
|
let aborted = false;
|
|
150
179
|
let stopRequested = false;
|
|
151
180
|
let forceRequested = false;
|
|
181
|
+
let processTreeKillConfirmed = platform !== 'win32';
|
|
182
|
+
let treeKillFailed = false;
|
|
152
183
|
let directClosed = false;
|
|
153
184
|
let directCode = null;
|
|
154
185
|
let timer = null;
|
|
@@ -217,7 +248,14 @@ export function runProcess(command, args, options = {}) {
|
|
|
217
248
|
const terminationConfirmed = () => {
|
|
218
249
|
if (!options.requireExitConfirmation) return directClosed;
|
|
219
250
|
const scopeInactive = isSystemdScopeInactive(options.systemdScope, spawnProcessSync);
|
|
220
|
-
|
|
251
|
+
const processTreeInactive = !options.requireProcessGroupExit
|
|
252
|
+
|| (platform === 'win32'
|
|
253
|
+
? processTreeKillConfirmed
|
|
254
|
+
: processGroupIsInactive(proc.pid));
|
|
255
|
+
const treeKillSucceeded = platform !== 'win32'
|
|
256
|
+
|| !options.requireProcessGroupExit
|
|
257
|
+
|| !treeKillFailed;
|
|
258
|
+
return directClosed && scopeInactive && processTreeInactive && treeKillSucceeded;
|
|
221
259
|
};
|
|
222
260
|
const maybeFinishStopped = () => {
|
|
223
261
|
if (settled || !stopRequested || !terminationConfirmed()) return false;
|
|
@@ -227,18 +265,30 @@ export function runProcess(command, args, options = {}) {
|
|
|
227
265
|
const startConfirmationPolling = () => {
|
|
228
266
|
if (!options.requireExitConfirmation || confirmationTimer) return;
|
|
229
267
|
confirmationTimer = setInterval(maybeFinishStopped, CONFIRMATION_POLL_MS);
|
|
268
|
+
if (!options.requireProcessGroupExit) confirmationTimer.unref?.();
|
|
230
269
|
};
|
|
231
270
|
const forceStop = () => {
|
|
232
271
|
if (settled || forceRequested) return;
|
|
233
272
|
forceRequested = true;
|
|
273
|
+
const settleBudget = terminationBudget(forceSettleMs);
|
|
234
274
|
killProcessTree(
|
|
235
275
|
proc,
|
|
236
276
|
'SIGKILL',
|
|
237
277
|
platform,
|
|
238
278
|
spawnProcessSync,
|
|
239
279
|
options.systemdScope,
|
|
280
|
+
Math.max(1, treeKillBudget() || 1),
|
|
240
281
|
);
|
|
241
282
|
if (maybeFinishStopped()) return;
|
|
283
|
+
if (settleBudget <= 0) {
|
|
284
|
+
finish(
|
|
285
|
+
null,
|
|
286
|
+
options.requireExitConfirmation
|
|
287
|
+
? new ProcessTerminationError(command, forceSettleMs)
|
|
288
|
+
: null,
|
|
289
|
+
);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
242
292
|
forceSettleTimer = setTimeout(() => {
|
|
243
293
|
if (maybeFinishStopped()) return;
|
|
244
294
|
finish(
|
|
@@ -247,7 +297,7 @@ export function runProcess(command, args, options = {}) {
|
|
|
247
297
|
? new ProcessTerminationError(command, forceSettleMs)
|
|
248
298
|
: null,
|
|
249
299
|
);
|
|
250
|
-
},
|
|
300
|
+
}, settleBudget);
|
|
251
301
|
};
|
|
252
302
|
const stop = () => {
|
|
253
303
|
if (settled || stopRequested) return;
|
|
@@ -256,20 +306,35 @@ export function runProcess(command, args, options = {}) {
|
|
|
256
306
|
// taskkill must run while the parent PID still identifies the tree.
|
|
257
307
|
// It is already forceful, so do not wait for the direct child to exit.
|
|
258
308
|
forceRequested = true;
|
|
259
|
-
|
|
309
|
+
const settleBudget = terminationBudget(forceSettleMs);
|
|
310
|
+
processTreeKillConfirmed = killProcessTree(
|
|
311
|
+
proc,
|
|
312
|
+
'SIGKILL',
|
|
313
|
+
platform,
|
|
314
|
+
spawnProcessSync,
|
|
315
|
+
null,
|
|
316
|
+
Math.max(1, treeKillBudget() || 1),
|
|
317
|
+
);
|
|
318
|
+
treeKillFailed = !processTreeKillConfirmed;
|
|
319
|
+
if (maybeFinishStopped()) return;
|
|
260
320
|
if (!settled) {
|
|
261
|
-
|
|
321
|
+
const finishAfterForce = () => {
|
|
322
|
+
if (maybeFinishStopped()) return;
|
|
262
323
|
finish(
|
|
263
324
|
null,
|
|
264
325
|
options.requireExitConfirmation
|
|
265
326
|
? new ProcessTerminationError(command, forceSettleMs)
|
|
266
327
|
: null,
|
|
267
328
|
);
|
|
268
|
-
}
|
|
329
|
+
};
|
|
330
|
+
const remainingSettleBudget = terminationBudget(forceSettleMs);
|
|
331
|
+
if (remainingSettleBudget <= 0) finishAfterForce();
|
|
332
|
+
else forceSettleTimer = setTimeout(finishAfterForce, remainingSettleBudget);
|
|
269
333
|
}
|
|
270
334
|
return;
|
|
271
335
|
}
|
|
272
336
|
startConfirmationPolling();
|
|
337
|
+
if (maybeFinishStopped()) return;
|
|
273
338
|
killProcessTree(
|
|
274
339
|
proc,
|
|
275
340
|
'SIGTERM',
|
|
@@ -277,8 +342,12 @@ export function runProcess(command, args, options = {}) {
|
|
|
277
342
|
spawnProcessSync,
|
|
278
343
|
options.systemdScope,
|
|
279
344
|
);
|
|
280
|
-
|
|
281
|
-
|
|
345
|
+
const graceBudget = deadlineBudget(killGraceMs, options.gracefulTerminationDeadline);
|
|
346
|
+
if (graceBudget <= 0) forceStop();
|
|
347
|
+
else {
|
|
348
|
+
forceTimer = setTimeout(forceStop, graceBudget);
|
|
349
|
+
if (!options.requireProcessGroupExit) forceTimer.unref?.();
|
|
350
|
+
}
|
|
282
351
|
};
|
|
283
352
|
const onAbort = () => {
|
|
284
353
|
aborted = true;
|
|
@@ -321,7 +390,7 @@ export function runProcess(command, args, options = {}) {
|
|
|
321
390
|
if (settled) return;
|
|
322
391
|
if (stopRequested) {
|
|
323
392
|
directClosed = true;
|
|
324
|
-
finishStoppedChild();
|
|
393
|
+
if (platform !== 'win32' || !treeKillFailed || !options.requireProcessGroupExit) finishStoppedChild();
|
|
325
394
|
return;
|
|
326
395
|
}
|
|
327
396
|
settled = true;
|
|
@@ -331,8 +400,12 @@ export function runProcess(command, args, options = {}) {
|
|
|
331
400
|
onClose = code => {
|
|
332
401
|
directClosed = true;
|
|
333
402
|
directCode = code;
|
|
403
|
+
if (!stopRequested && options.requireProcessGroupExit && !terminationConfirmed()) {
|
|
404
|
+
stop();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
334
407
|
if (stopRequested) {
|
|
335
|
-
finishStoppedChild();
|
|
408
|
+
if (platform !== 'win32' || !treeKillFailed || !options.requireProcessGroupExit) finishStoppedChild();
|
|
336
409
|
return;
|
|
337
410
|
}
|
|
338
411
|
finish(code);
|