@logicseek/hello-cc 0.1.9 → 1.0.1
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/CHANGELOG.md +136 -0
- package/README.md +50 -3
- package/README.zh-CN.md +48 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/guide.md +97 -3
- package/docs/guide.zh-CN.md +89 -2
- package/lib/cli/app.mjs +644 -0
- package/lib/cli/commands/coordination.mjs +249 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +1002 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +76 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +249 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +222 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +48 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +63 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +377 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +250 -0
- package/lib/terminal/tmux.mjs +131 -27
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +183 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +717 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +335 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +6 -3
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5404 -273
- package/docs/architecture-split-review.md +0 -217
- package/docs/layout-split-stack-audit.md +0 -298
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Runtime-side automatic buffer GC, extracted from lib/web/runtime-main.mjs.
|
|
2
|
+
// Plans and applies buffer-file GC with runtime arbitration: live-session
|
|
3
|
+
// path snapshots, cross-project evidence collection, fenced plan tokens,
|
|
4
|
+
// and the periodic auto-GC sweep.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { CliError } from '../shared/errors.mjs';
|
|
8
|
+
import { clockSafetyUnavailable } from '../core/coordination/clock-safety.mjs';
|
|
9
|
+
import { collectBufferEvidence } from '../runtime/buffer-evidence.mjs';
|
|
10
|
+
import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
|
|
11
|
+
import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
|
|
12
|
+
|
|
13
|
+
export function createBufferGcRuntime(deps) {
|
|
14
|
+
const {
|
|
15
|
+
sessions,
|
|
16
|
+
connectWebProject, runtimeProjectContexts,
|
|
17
|
+
now, runGc, bufferDirectory,
|
|
18
|
+
canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
|
|
19
|
+
} = deps;
|
|
20
|
+
|
|
21
|
+
const bufferGcPlanStore = createBufferGcPlanStore();
|
|
22
|
+
const bufferUnknownTracker = new Map();
|
|
23
|
+
const bufferDirectoriesByProject = new Map();
|
|
24
|
+
|
|
25
|
+
// ── Automatic GC ──────────────────────────────────────────────────────────
|
|
26
|
+
// Bound events/WAL growth without manual `hcc gc` (conc-05). Auto scope only
|
|
27
|
+
// prunes high-volume/ephemeral items (events >14d, expired locks, stale bufs),
|
|
28
|
+
// never peers/tasks/messages/handoffs. Runs once at startup then every 6h.
|
|
29
|
+
let autoGcInFlight = false;
|
|
30
|
+
function runningBufferPathSnapshot(projectCtx) {
|
|
31
|
+
const protectedPaths = new Set();
|
|
32
|
+
const projectKey = canonicalRoot(projectCtx.root) || path.resolve(projectCtx.root);
|
|
33
|
+
const projectDirectories = bufferDirectoriesByProject.get(projectKey) || new Set();
|
|
34
|
+
for (const session of sessions.values()) {
|
|
35
|
+
for (const file of [session.outFile, session.inFile, session.resizeFile, session.pipeFile, session.metaFile]) {
|
|
36
|
+
if (!file) continue;
|
|
37
|
+
const resolved = path.resolve(file);
|
|
38
|
+
if (sameResolvedPath(session.root, projectCtx.root)) projectDirectories.add(path.dirname(resolved));
|
|
39
|
+
if (session.status === 'running') protectedPaths.add(resolved);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
bufferDirectoriesByProject.set(projectKey, projectDirectories);
|
|
43
|
+
return { protectedPaths, projectDirectories };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectRuntimeBufferEvidence(directories, candidatePaths = null) {
|
|
47
|
+
const projectDbs = [];
|
|
48
|
+
const opened = [];
|
|
49
|
+
try {
|
|
50
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
51
|
+
try {
|
|
52
|
+
const db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
|
|
53
|
+
opened.push(db);
|
|
54
|
+
projectDbs.push({ ctx: projectCtx, db });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
projectDbs.push({
|
|
57
|
+
ctx: projectCtx,
|
|
58
|
+
db: { prepare() { throw error; } }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return collectBufferEvidence({
|
|
63
|
+
directories,
|
|
64
|
+
projectDbs,
|
|
65
|
+
sessions: [...sessions.values()],
|
|
66
|
+
observePeer: observePeerEvidence,
|
|
67
|
+
unknownTracker: bufferUnknownTracker,
|
|
68
|
+
candidatePaths
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
for (const db of opened) {
|
|
72
|
+
try { db.close(); } catch {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function prepareRuntimeBufferGc(projectCtx, { dryRun, retentionSec }) {
|
|
78
|
+
const observedAt = now();
|
|
79
|
+
const cutoffMs = observedAt * 1000 - retentionSec * 1000;
|
|
80
|
+
const { projectDirectories } = runningBufferPathSnapshot(projectCtx);
|
|
81
|
+
const directories = new Set([bufferDirectory(projectCtx), ...projectDirectories]);
|
|
82
|
+
const { protectedPaths, unknownPaths, gcCutoffs } = collectRuntimeBufferEvidence(directories);
|
|
83
|
+
const plan = planBufferFiles({
|
|
84
|
+
directories,
|
|
85
|
+
cutoffMs,
|
|
86
|
+
protectedPaths,
|
|
87
|
+
unknownPaths,
|
|
88
|
+
evidenceGcCutoffs: gcCutoffs
|
|
89
|
+
});
|
|
90
|
+
const prepared = {
|
|
91
|
+
observedAt,
|
|
92
|
+
cutoffMs,
|
|
93
|
+
retentionSec,
|
|
94
|
+
deleted: plan.deleteEntries.length,
|
|
95
|
+
protected: plan.protectedEntries.length,
|
|
96
|
+
deferred: plan.unknownEntries.length,
|
|
97
|
+
gcCutoffs: bufferPlanGcCutoffs(plan, retentionSec)
|
|
98
|
+
};
|
|
99
|
+
if (!dryRun) {
|
|
100
|
+
prepared.token = bufferGcPlanStore.prepare({
|
|
101
|
+
root: projectCtx.root,
|
|
102
|
+
dbPath: projectCtx.dbPath,
|
|
103
|
+
observedAt,
|
|
104
|
+
retentionSec,
|
|
105
|
+
plan
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return prepared;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function applyPreparedRuntimeBufferGc(projectCtx, token) {
|
|
112
|
+
const prepared = bufferGcPlanStore.take({
|
|
113
|
+
token,
|
|
114
|
+
root: projectCtx.root,
|
|
115
|
+
dbPath: projectCtx.dbPath
|
|
116
|
+
});
|
|
117
|
+
let db = null;
|
|
118
|
+
try {
|
|
119
|
+
db = connectWebProject(projectCtx);
|
|
120
|
+
return applyClockSafeBufferPlan({
|
|
121
|
+
db,
|
|
122
|
+
plan: prepared.plan,
|
|
123
|
+
retentionSec: prepared.retentionSec,
|
|
124
|
+
nowSec: now,
|
|
125
|
+
collectEvidence: ({ entries }) => collectRuntimeBufferEvidence(
|
|
126
|
+
new Set(entries.map((entry) => entry.directory.path)),
|
|
127
|
+
entries.map((entry) => entry.path)
|
|
128
|
+
)
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof CliError) throw error;
|
|
132
|
+
throw clockSafetyUnavailable(error);
|
|
133
|
+
} finally {
|
|
134
|
+
try { db?.close(); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runAutoGc() {
|
|
139
|
+
if (autoGcInFlight) return;
|
|
140
|
+
autoGcInFlight = true;
|
|
141
|
+
try {
|
|
142
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
143
|
+
let db = null;
|
|
144
|
+
try {
|
|
145
|
+
db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
|
|
146
|
+
const result = runGc(projectCtx, db, {
|
|
147
|
+
olderThanDays: 14,
|
|
148
|
+
dryRun: false,
|
|
149
|
+
scope: 'auto',
|
|
150
|
+
collectBufferEvidenceNow: (directory) => collectRuntimeBufferEvidence([
|
|
151
|
+
directory
|
|
152
|
+
])
|
|
153
|
+
});
|
|
154
|
+
const appliedDatabaseRows = result.expired_locks + result.old_events;
|
|
155
|
+
if (appliedDatabaseRows > 0) {
|
|
156
|
+
try { db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get(); } catch {}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] auto-gc failed for ${projectCtx.root}: ${err?.message || err}`));
|
|
160
|
+
} finally {
|
|
161
|
+
try { db?.close(); } catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
autoGcInFlight = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const gcPoller = setInterval(runAutoGc, 6 * 60 * 60 * 1000);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
gcPoller, runAutoGc,
|
|
172
|
+
collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
173
|
+
runningBufferPathSnapshot, bufferGcPlanStore
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Web cookie/session authentication helpers extracted from cmdWeb.
|
|
2
|
+
// Factory pattern: the webSessions Map and config are created by the factory
|
|
3
|
+
// and shared across all returned functions.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
export function createCookieAuth({
|
|
8
|
+
now,
|
|
9
|
+
ttlSec,
|
|
10
|
+
maxSessions,
|
|
11
|
+
requestIsSecure,
|
|
12
|
+
trustProxy,
|
|
13
|
+
proxyOrigin,
|
|
14
|
+
authOk,
|
|
15
|
+
token
|
|
16
|
+
}) {
|
|
17
|
+
const webSessions = new Map();
|
|
18
|
+
|
|
19
|
+
function parseCookieSid(req) {
|
|
20
|
+
const header = req.headers.cookie || '';
|
|
21
|
+
for (const part of header.split(';')) {
|
|
22
|
+
const [k, ...rest] = part.trim().split('=');
|
|
23
|
+
if (k === 'hcc_sid') {
|
|
24
|
+
const raw = rest.join('=');
|
|
25
|
+
if (raw.length > 128) return '';
|
|
26
|
+
try { return decodeURIComponent(raw); } catch { return ''; }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function closeWebSession(sid, reason = 'session revoked') {
|
|
33
|
+
const session = webSessions.get(sid);
|
|
34
|
+
webSessions.delete(sid);
|
|
35
|
+
for (const ws of session?.sockets || []) {
|
|
36
|
+
try { ws.close(4001, reason); } catch {}
|
|
37
|
+
}
|
|
38
|
+
session?.sockets?.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pruneWebSessions(t = now()) {
|
|
42
|
+
for (const [sid, session] of webSessions) {
|
|
43
|
+
if (session.expiresAt <= t) closeWebSession(sid, 'session expired');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function issueSession() {
|
|
48
|
+
pruneWebSessions();
|
|
49
|
+
while (webSessions.size >= maxSessions) {
|
|
50
|
+
const oldest = webSessions.keys().next().value;
|
|
51
|
+
if (!oldest) break;
|
|
52
|
+
closeWebSession(oldest, 'session limit reached');
|
|
53
|
+
}
|
|
54
|
+
const sid = randomBytes(24).toString('base64url');
|
|
55
|
+
webSessions.set(sid, { expiresAt: now() + ttlSec, sockets: new Set() });
|
|
56
|
+
return sid;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sessionCookieHeader(sid, req) {
|
|
60
|
+
const parts = [`hcc_sid=${sid}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${ttlSec}`];
|
|
61
|
+
if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
|
|
62
|
+
return parts.join('; ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function expiredSessionCookieHeader(req) {
|
|
66
|
+
const parts = ['hcc_sid=', 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
|
|
67
|
+
if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
|
|
68
|
+
return parts.join('; ');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cookieSessionRecord(req) {
|
|
72
|
+
const sid = parseCookieSid(req);
|
|
73
|
+
if (!sid) return null;
|
|
74
|
+
const session = webSessions.get(sid);
|
|
75
|
+
if (!session || session.expiresAt <= now()) {
|
|
76
|
+
if (session) closeWebSession(sid, 'session expired');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { sid, session };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function cookieSessionOk(req) {
|
|
83
|
+
return Boolean(cookieSessionRecord(req));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cookieSocketValid(ws) {
|
|
87
|
+
const auth = ws?.hccCookieAuth;
|
|
88
|
+
if (!auth) return true;
|
|
89
|
+
const current = webSessions.get(auth.sid);
|
|
90
|
+
if (current === auth.session && current.expiresAt > now()) return true;
|
|
91
|
+
const reason = current === auth.session ? 'session expired' : 'session revoked';
|
|
92
|
+
if (current === auth.session) {
|
|
93
|
+
closeWebSession(auth.sid, reason);
|
|
94
|
+
} else {
|
|
95
|
+
auth.session.sockets.delete(ws);
|
|
96
|
+
try { ws.close(4001, reason); } catch {}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function webAuthMode(url, req) {
|
|
102
|
+
if (authOk(url, req, token)) return 'token';
|
|
103
|
+
return cookieSessionOk(req) ? 'cookie' : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
webSessions,
|
|
108
|
+
parseCookieSid,
|
|
109
|
+
closeWebSession,
|
|
110
|
+
pruneWebSessions,
|
|
111
|
+
issueSession,
|
|
112
|
+
sessionCookieHeader,
|
|
113
|
+
expiredSessionCookieHeader,
|
|
114
|
+
cookieSessionRecord,
|
|
115
|
+
cookieSessionOk,
|
|
116
|
+
cookieSocketValid,
|
|
117
|
+
webAuthMode
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// External buffer-file session adoption, extracted from lib/web/runtime-main.mjs.
|
|
2
|
+
// Adopts hcc-run PTY bridges from .hello-cc/bufs/<id>.* files: owner-evidence
|
|
3
|
+
// checks under the producer lease, output polling, exit reconciliation, and
|
|
4
|
+
// per-project directory watchers.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
|
|
9
|
+
import {
|
|
10
|
+
externalBufferEvidence,
|
|
11
|
+
externalBufferOwnerKey,
|
|
12
|
+
externalBufferSessionIds,
|
|
13
|
+
readExternalBufferMetadata
|
|
14
|
+
} from '../runtime/buffer-evidence.mjs';
|
|
15
|
+
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
16
|
+
|
|
17
|
+
export function createExternalSessions(deps) {
|
|
18
|
+
const {
|
|
19
|
+
ctx, sessions,
|
|
20
|
+
sessionKey, broadcast,
|
|
21
|
+
now, tx, connectWebProject,
|
|
22
|
+
bufferDirectory, runtimeProjectContexts,
|
|
23
|
+
refreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
|
|
24
|
+
} = deps;
|
|
25
|
+
|
|
26
|
+
// ── Optional external buffer-file session adoption ───────────────────────
|
|
27
|
+
const bufsDir = path.join(ctx.root, '.hello-cc', BUFS_DIR_NAME);
|
|
28
|
+
fs.mkdirSync(bufsDir, { recursive: true });
|
|
29
|
+
|
|
30
|
+
function removeExternalBufferFiles(id, directory = bufsDir) {
|
|
31
|
+
for (const suffix of ['out', 'in', 'resize', 'meta']) {
|
|
32
|
+
fs.rmSync(path.join(directory, `${id}.${suffix}`), { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
|
|
37
|
+
const key = sessionKey(pctx, id);
|
|
38
|
+
if (sessions.has(key)) return;
|
|
39
|
+
const outFile = path.join(directory, `${id}.out`);
|
|
40
|
+
const inFile = path.join(directory, `${id}.in`);
|
|
41
|
+
const resizeFile = path.join(directory, `${id}.resize`);
|
|
42
|
+
const metaFile = path.join(directory, `${id}.meta`);
|
|
43
|
+
let adopted = null;
|
|
44
|
+
try {
|
|
45
|
+
adopted = withBufferDirectoryLease(directory, () => {
|
|
46
|
+
if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
|
|
47
|
+
let meta;
|
|
48
|
+
try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
|
|
49
|
+
// The producer publishes wrapper evidence before the PTY child identity
|
|
50
|
+
// is complete. Do not cache that transitional snapshot as a session;
|
|
51
|
+
// the next scan adopts the final metadata instead.
|
|
52
|
+
if (meta.publishing === true) return null;
|
|
53
|
+
const ownerKey = externalBufferOwnerKey(meta);
|
|
54
|
+
if (!ownerKey) return null;
|
|
55
|
+
const evidence = externalBufferEvidence(meta, inspectProcessIdentity);
|
|
56
|
+
// Re-read and remove in one producer-coordinated lease. A new producer
|
|
57
|
+
// with the same id cannot publish between this decision and deletion.
|
|
58
|
+
if (evidence.state === 'dead') {
|
|
59
|
+
removeExternalBufferFiles(id, directory);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { meta, ownerKey };
|
|
63
|
+
});
|
|
64
|
+
} catch { return; }
|
|
65
|
+
if (!adopted) return;
|
|
66
|
+
const { meta, ownerKey } = adopted;
|
|
67
|
+
const wrapperOwnerPid = meta.wrapper_pid || meta.wrapperPid || null;
|
|
68
|
+
const wrapperOwnerIdentity = meta.wrapper_identity || meta.wrapperIdentity || null;
|
|
69
|
+
const dbOwnerPid = wrapperOwnerPid || meta.pid || null;
|
|
70
|
+
const dbOwnerIdentity = wrapperOwnerPid
|
|
71
|
+
? wrapperOwnerIdentity
|
|
72
|
+
: meta.child_identity || meta.childIdentity || null;
|
|
73
|
+
|
|
74
|
+
const session = {
|
|
75
|
+
id,
|
|
76
|
+
peerId: id,
|
|
77
|
+
actionTokens: new Set(),
|
|
78
|
+
root: pctx.root,
|
|
79
|
+
ctx: pctx,
|
|
80
|
+
kind: meta.kind || 'external',
|
|
81
|
+
role: meta.role || 'peer',
|
|
82
|
+
command: meta.command || '(shim)',
|
|
83
|
+
cwd: meta.cwd || pctx.root,
|
|
84
|
+
pid: meta.pid || null,
|
|
85
|
+
wrapperPid: meta.wrapper_pid || null,
|
|
86
|
+
childIdentity: meta.child_identity || null,
|
|
87
|
+
wrapperIdentity: meta.wrapper_identity || null,
|
|
88
|
+
externalOwnerKey: ownerKey,
|
|
89
|
+
externalDbOwner: Number.isInteger(Number(dbOwnerPid)) && dbOwnerIdentity?.startToken &&
|
|
90
|
+
dbOwnerIdentity?.commandHash
|
|
91
|
+
? {
|
|
92
|
+
pid: Number(dbOwnerPid),
|
|
93
|
+
startToken: dbOwnerIdentity.startToken,
|
|
94
|
+
commandHash: dbOwnerIdentity.commandHash
|
|
95
|
+
}
|
|
96
|
+
: null,
|
|
97
|
+
type: 'external',
|
|
98
|
+
outFile, inFile, resizeFile, metaFile,
|
|
99
|
+
status: 'running',
|
|
100
|
+
createdAt: now(),
|
|
101
|
+
exitedAt: null,
|
|
102
|
+
buffer: '',
|
|
103
|
+
clients: new Set(),
|
|
104
|
+
outputPoller: null,
|
|
105
|
+
outputFd: null,
|
|
106
|
+
exitPoller: null,
|
|
107
|
+
};
|
|
108
|
+
// Load existing output as initial snapshot
|
|
109
|
+
try { session.buffer = fs.readFileSync(outFile, 'utf8'); } catch {}
|
|
110
|
+
sessions.set(key, session);
|
|
111
|
+
|
|
112
|
+
// Open a persistent fd for polling output; fstatSync is cheap.
|
|
113
|
+
let outputOffset = 0;
|
|
114
|
+
try {
|
|
115
|
+
session.outputFd = fs.openSync(outFile, 'r');
|
|
116
|
+
outputOffset = fs.fstatSync(session.outputFd).size;
|
|
117
|
+
} catch {}
|
|
118
|
+
session.outputPoller = setInterval(() => {
|
|
119
|
+
try {
|
|
120
|
+
if (session.outputFd === null) return;
|
|
121
|
+
const stat = fs.fstatSync(session.outputFd);
|
|
122
|
+
if (stat.size < outputOffset) outputOffset = 0;
|
|
123
|
+
if (stat.size <= outputOffset) return;
|
|
124
|
+
const buf = Buffer.alloc(stat.size - outputOffset);
|
|
125
|
+
fs.readSync(session.outputFd, buf, 0, buf.length, outputOffset);
|
|
126
|
+
outputOffset = stat.size;
|
|
127
|
+
const data = buf.toString();
|
|
128
|
+
session.buffer += data;
|
|
129
|
+
if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
|
|
130
|
+
broadcast(session, { type: 'data', data });
|
|
131
|
+
refreshPeerIoHeartbeat(session);
|
|
132
|
+
} catch {
|
|
133
|
+
// File removed or truncated — close and stop polling
|
|
134
|
+
if (session.outputFd) { try { fs.closeSync(session.outputFd); } catch {} session.outputFd = null; }
|
|
135
|
+
}
|
|
136
|
+
}, 100);
|
|
137
|
+
|
|
138
|
+
function finalizeExternalSession({ updateDatabase }) {
|
|
139
|
+
session.status = 'exited';
|
|
140
|
+
session.exitedAt = now();
|
|
141
|
+
broadcast(session, { type: 'exit', event: {} });
|
|
142
|
+
if (updateDatabase) {
|
|
143
|
+
try {
|
|
144
|
+
const exitDb = connectWebProject(session.ctx || ctx);
|
|
145
|
+
try {
|
|
146
|
+
const exitPeerId = session.peerId || session.id;
|
|
147
|
+
if (session.externalDbOwner) {
|
|
148
|
+
tx(exitDb, () => {
|
|
149
|
+
const mutation = exitDb.prepare(`
|
|
150
|
+
UPDATE peers SET status = ?
|
|
151
|
+
WHERE id = ? AND pid = ? AND pid_start_token = ? AND pid_command_hash = ?
|
|
152
|
+
`).run(
|
|
153
|
+
'exited',
|
|
154
|
+
exitPeerId,
|
|
155
|
+
session.externalDbOwner.pid,
|
|
156
|
+
session.externalDbOwner.startToken,
|
|
157
|
+
session.externalDbOwner.commandHash
|
|
158
|
+
);
|
|
159
|
+
if (Number(mutation.changes || 0) > 0) {
|
|
160
|
+
exitDb.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ?')
|
|
161
|
+
.run(now(), exitPeerId);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} finally { exitDb.close(); }
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
if (session.outputFd) { try { fs.closeSync(session.outputFd); } catch {} session.outputFd = null; }
|
|
169
|
+
if (session.outputPoller) clearInterval(session.outputPoller);
|
|
170
|
+
if (session.exitPoller) clearInterval(session.exitPoller);
|
|
171
|
+
sessions.delete(key);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Reconcile and mutate the external group under the same lease used by its
|
|
175
|
+
// producer. Owner changes detach only this stale in-memory view; they never
|
|
176
|
+
// mark the replacement producer exited or remove its files.
|
|
177
|
+
session.exitPoller = setInterval(() => {
|
|
178
|
+
try {
|
|
179
|
+
withBufferDirectoryLease(directory, () => {
|
|
180
|
+
const outExists = fs.existsSync(outFile);
|
|
181
|
+
let currentMeta;
|
|
182
|
+
try {
|
|
183
|
+
currentMeta = readExternalBufferMetadata(metaFile);
|
|
184
|
+
} catch {
|
|
185
|
+
// A producer removes the whole group in one lease. No out and no
|
|
186
|
+
// metadata is therefore a clean exit; an unreadable metadata file
|
|
187
|
+
// is unknown and must not mutate DB ownership.
|
|
188
|
+
if (!outExists && !fs.existsSync(metaFile)) {
|
|
189
|
+
finalizeExternalSession({ updateDatabase: true });
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const currentOwnerKey = externalBufferOwnerKey(currentMeta);
|
|
194
|
+
if (!currentOwnerKey) return;
|
|
195
|
+
if (currentOwnerKey !== session.externalOwnerKey) {
|
|
196
|
+
finalizeExternalSession({ updateDatabase: false });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!outExists) {
|
|
200
|
+
finalizeExternalSession({ updateDatabase: true });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (externalBufferEvidence(currentMeta, inspectProcessIdentity).state !== 'dead') return;
|
|
204
|
+
removeExternalBufferFiles(id, directory);
|
|
205
|
+
finalizeExternalSession({ updateDatabase: true });
|
|
206
|
+
});
|
|
207
|
+
} catch {}
|
|
208
|
+
}, 2000);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Adopt any already-running external sessions
|
|
212
|
+
function scanExternalSessions() {
|
|
213
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
214
|
+
const directory = bufferDirectory(projectCtx);
|
|
215
|
+
for (const id of externalBufferSessionIds(directory)) {
|
|
216
|
+
adoptExternalSession(id, projectCtx, directory);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
scanExternalSessions();
|
|
222
|
+
|
|
223
|
+
const externalScanPoller = setInterval(scanExternalSessions, 1000);
|
|
224
|
+
|
|
225
|
+
// bg-04: watch for new external sessions appearing in EVERY registered
|
|
226
|
+
// project's bufsDir, not just the primary. New projects discovered by
|
|
227
|
+
// scanExternalSessions get their own watcher on the next scan tick.
|
|
228
|
+
const bufsWatchers = new Map();
|
|
229
|
+
function ensureBufsWatchers() {
|
|
230
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
231
|
+
const root = path.resolve(projectCtx.root);
|
|
232
|
+
if (bufsWatchers.has(root)) continue;
|
|
233
|
+
const directory = bufferDirectory(projectCtx);
|
|
234
|
+
try {
|
|
235
|
+
const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
|
|
236
|
+
if (filename?.endsWith('.out')) {
|
|
237
|
+
setTimeout(() => adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory), 300);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
watcher.on('error', (err) => {
|
|
241
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] bufs watcher error for ${root}: ${err?.message || err}`));
|
|
242
|
+
});
|
|
243
|
+
bufsWatchers.set(root, watcher);
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
ensureBufsWatchers();
|
|
248
|
+
const bufsWatcherSyncPoller = setInterval(ensureBufsWatchers, 5000);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
|
|
252
|
+
scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
|
|
253
|
+
};
|
|
254
|
+
}
|