accrete-kit 0.1.1 → 0.2.0
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 +9 -0
- package/README.md +9 -2
- package/dist/scaffold/assets/generated/release.json +1 -1
- package/dist/scaffold/manifest.json +2 -2
- package/dist/ui/LICENSE +21 -0
- package/dist/ui/THIRD-PARTY-NOTICES +212 -0
- package/dist/ui/bin/accrete-ui.mjs +46 -0
- package/dist/ui/page/app.js +49 -0
- package/dist/ui/page/index.html +17 -0
- package/dist/ui/page/panels/activity.css +80 -0
- package/dist/ui/page/panels/activity.js +85 -0
- package/dist/ui/page/panels/agents.css +61 -0
- package/dist/ui/page/panels/agents.js +173 -0
- package/dist/ui/page/panels/objects.css +155 -0
- package/dist/ui/page/panels/objects.js +309 -0
- package/dist/ui/page/panels/running.css +35 -0
- package/dist/ui/page/panels/running.js +159 -0
- package/dist/ui/page/style.css +86 -0
- package/dist/ui/page/terminal.js +88 -0
- package/dist/ui/server/agent-sessions.mjs +600 -0
- package/dist/ui/server/auth.mjs +29 -0
- package/dist/ui/server/deps.mjs +12 -0
- package/dist/ui/server/documents.mjs +285 -0
- package/dist/ui/server/events.mjs +18 -0
- package/dist/ui/server/processes.mjs +110 -0
- package/dist/ui/server/readers/activity.mjs +134 -0
- package/dist/ui/server/readers/agents.mjs +45 -0
- package/dist/ui/server/readers/objects.mjs +261 -0
- package/dist/ui/server/readers/running.mjs +245 -0
- package/dist/ui/server/scope.mjs +36 -0
- package/dist/ui/server/server.mjs +156 -0
- package/dist/ui/server/terminal.mjs +134 -0
- package/dist/ui/server/workspace.mjs +26 -0
- package/dist/ui/ui.json +3 -0
- package/dist/ui/vendor/@xterm/addon-fit/lib/addon-fit.mjs +18 -0
- package/dist/ui/vendor/@xterm/addon-unicode11/lib/addon-unicode11.mjs +44 -0
- package/dist/ui/vendor/@xterm/addon-webgl/lib/addon-webgl.mjs +101 -0
- package/dist/ui/vendor/@xterm/xterm/css/xterm.css +285 -0
- package/dist/ui/vendor/@xterm/xterm/lib/xterm.mjs +53 -0
- package/dist/ui/vendor/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/ui/vendor/node-pty/lib/index.js +52 -0
- package/dist/ui/vendor/node-pty/lib/interfaces.js +7 -0
- package/dist/ui/vendor/node-pty/lib/terminal.js +190 -0
- package/dist/ui/vendor/node-pty/lib/types.js +7 -0
- package/dist/ui/vendor/node-pty/lib/unixTerminal.js +346 -0
- package/dist/ui/vendor/node-pty/lib/utils.js +39 -0
- package/dist/ui/vendor/node-pty/package.json +64 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/ui/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/ui/vendor/ws/index.js +22 -0
- package/dist/ui/vendor/ws/lib/buffer-util.js +142 -0
- package/dist/ui/vendor/ws/lib/constants.js +19 -0
- package/dist/ui/vendor/ws/lib/event-target.js +292 -0
- package/dist/ui/vendor/ws/lib/extension.js +203 -0
- package/dist/ui/vendor/ws/lib/limiter.js +55 -0
- package/dist/ui/vendor/ws/lib/permessage-deflate.js +530 -0
- package/dist/ui/vendor/ws/lib/receiver.js +743 -0
- package/dist/ui/vendor/ws/lib/sender.js +610 -0
- package/dist/ui/vendor/ws/lib/stream.js +161 -0
- package/dist/ui/vendor/ws/lib/subprotocol.js +62 -0
- package/dist/ui/vendor/ws/lib/validation.js +152 -0
- package/dist/ui/vendor/ws/lib/websocket-server.js +562 -0
- package/dist/ui/vendor/ws/lib/websocket.js +1426 -0
- package/dist/ui/vendor/ws/package.json +74 -0
- package/lib/cli.mjs +22 -4
- package/lib/ui.mjs +24 -0
- package/package.json +5 -5
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Reads the Workspace for the objects panel. Documents are primary and always list
|
|
2
|
+
// and open. Relation rows come from the index, because it normalizes their shapes.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { KINDS, openStore, revisionOf } from '../documents.mjs';
|
|
8
|
+
import { resolveWorkspace } from '../workspace.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_PAGE = 1000;
|
|
11
|
+
const SAMPLES = 5;
|
|
12
|
+
|
|
13
|
+
let store = null;
|
|
14
|
+
let indexRevisions = new Map(); // path -> revision, as of the last read of the index
|
|
15
|
+
let indexPresent = false;
|
|
16
|
+
|
|
17
|
+
function query(index, sql, attempt = 0) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const args = ['-batch', '-init', '/dev/null', '-readonly', '-json', index, sql];
|
|
20
|
+
execFile('sqlite3', args, { maxBuffer: 2 ** 28, timeout: 20000 }, (err, stdout, stderr) => {
|
|
21
|
+
if (err) {
|
|
22
|
+
const message = stderr.trim() || err.message;
|
|
23
|
+
if (attempt < 10 && /locked|busy/i.test(message)) {
|
|
24
|
+
return setTimeout(() => query(index, sql, attempt + 1).then(resolve, reject), 100);
|
|
25
|
+
}
|
|
26
|
+
return reject(new Error(message));
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
resolve(stdout.trim() ? JSON.parse(stdout) : []);
|
|
30
|
+
} catch (parseError) {
|
|
31
|
+
reject(parseError);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DOCUMENT_ROWS = ['models', 'iterations', 'tools', 'data_objects', 'results', 'memory_entries']
|
|
38
|
+
.map((table) => `SELECT path, revision FROM ${table}`)
|
|
39
|
+
.join(' UNION ALL ');
|
|
40
|
+
|
|
41
|
+
// What the index holds against what the documents hold, in plain counts and names.
|
|
42
|
+
async function readIndex() {
|
|
43
|
+
const index = store.workspace.local_state?.index;
|
|
44
|
+
let state;
|
|
45
|
+
let rows = [];
|
|
46
|
+
let failure;
|
|
47
|
+
if (index && existsSync(index)) {
|
|
48
|
+
try {
|
|
49
|
+
[state] = await query(index, 'SELECT refreshed_at, object_count FROM index_state');
|
|
50
|
+
rows = await query(index, DOCUMENT_ROWS);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// A refresh creates the file a moment before its tables.
|
|
53
|
+
if (!/no such table/.test(err.message)) failure = err.message;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
indexPresent = Boolean(state) && !failure;
|
|
57
|
+
indexRevisions = new Map(rows.map((row) => [row.path, row.revision]));
|
|
58
|
+
if (failure) return { present: true, error: failure };
|
|
59
|
+
if (!state) return { present: false };
|
|
60
|
+
|
|
61
|
+
const changed = [];
|
|
62
|
+
const missing = [];
|
|
63
|
+
for (const doc of store.docs.values()) {
|
|
64
|
+
const revision = indexRevisions.get(doc.path);
|
|
65
|
+
if (revision === undefined) missing.push(doc.rel);
|
|
66
|
+
else if (revision !== doc.revision) changed.push(doc.rel);
|
|
67
|
+
}
|
|
68
|
+
const gone = rows.filter((row) => !store.docs.has(row.path)).map((row) => store.relToBase(row.path));
|
|
69
|
+
return {
|
|
70
|
+
present: true,
|
|
71
|
+
refreshed_at: state.refreshed_at,
|
|
72
|
+
object_count: state.object_count,
|
|
73
|
+
changed: { count: changed.length, sample: changed.slice(0, SAMPLES) },
|
|
74
|
+
missing: { count: missing.length, sample: missing.slice(0, SAMPLES) },
|
|
75
|
+
gone: { count: gone.length, sample: gone.slice(0, SAMPLES) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function templateVersion(root) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(readFileSync(join(root, '.accrete/release.json'), 'utf8')).template_version ?? 'latest';
|
|
82
|
+
} catch {
|
|
83
|
+
return 'latest';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function start({ root, emit }) {
|
|
88
|
+
store = await openStore(root);
|
|
89
|
+
const install = store.workspace.kernel ? null : `npx accrete-kit@${templateVersion(root)} install`;
|
|
90
|
+
// A Kernel that is there but failed is worth naming; one that isn't there is the normal case.
|
|
91
|
+
const kernelError = install && !/ENOENT/.test(store.workspace.error ?? '') ? store.workspace.error : undefined;
|
|
92
|
+
let seq = 0;
|
|
93
|
+
|
|
94
|
+
async function publish() {
|
|
95
|
+
const counts = Object.fromEntries(KINDS.map((kind) => [kind, 0]));
|
|
96
|
+
for (const doc of store.docs.values()) counts[doc.kind] += 1;
|
|
97
|
+
const index = await readIndex();
|
|
98
|
+
emit('objects', { seq: ++seq, counts, index, install, kernel_error: kernelError });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
store.subscribe(publish);
|
|
102
|
+
await publish();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const timeOf = (doc) => doc.authored_at ?? doc.updated_at ?? doc.created_at ?? '';
|
|
106
|
+
|
|
107
|
+
function list(kind, offset, limit) {
|
|
108
|
+
const docs = [...store.docs.values()].filter((doc) => doc.kind === kind);
|
|
109
|
+
docs.sort((a, b) => timeOf(b).localeCompare(timeOf(a)) || a.rel.localeCompare(b.rel));
|
|
110
|
+
return {
|
|
111
|
+
kind,
|
|
112
|
+
total: docs.length,
|
|
113
|
+
offset,
|
|
114
|
+
items: docs.slice(offset, offset + limit).map((doc) => ({
|
|
115
|
+
id: doc.id,
|
|
116
|
+
name: doc.name,
|
|
117
|
+
status: doc.status,
|
|
118
|
+
rel: doc.rel,
|
|
119
|
+
time: timeOf(doc) || undefined,
|
|
120
|
+
problem: doc.problem,
|
|
121
|
+
})),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const quote = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
126
|
+
|
|
127
|
+
// A reference holds whatever its author wrote, and the Kernel accepts a name or alias
|
|
128
|
+
// where an ID goes: `memory append --model NAME` stores NAME. So reverse lookups match
|
|
129
|
+
// those too, and a forward lookup falls back to them.
|
|
130
|
+
function relationSql(id, doc) {
|
|
131
|
+
const me = quote(id);
|
|
132
|
+
const us = `(${[id, doc?.name, ...(doc?.aliases ?? [])].filter(Boolean).map(quote).join(',')})`;
|
|
133
|
+
const dataDoc = '(SELECT document FROM results r WHERE r.id = d.result_id)';
|
|
134
|
+
const origin = "json_extract(document, '$.origin.result_id')";
|
|
135
|
+
return [
|
|
136
|
+
`SELECT 'Models used' section, 'model' kind, model_id target, role, commit_id, NULL revision, document link, NULL result_doc FROM result_models WHERE result_id = ${me}`,
|
|
137
|
+
`SELECT 'Tools used', 'tool', tool_id, NULL, NULL, revision_used, document, NULL FROM result_tools WHERE result_id = ${me}`,
|
|
138
|
+
`SELECT 'Data', 'data', data_id, role, NULL, NULL, document, ${dataDoc} FROM result_data d WHERE result_id = ${me}`,
|
|
139
|
+
`SELECT 'Data from this Result', 'data', id, NULL, NULL, NULL, NULL, NULL FROM data_objects WHERE ${origin} = ${me}`,
|
|
140
|
+
`SELECT 'Model', 'model', model_id, NULL, NULL, starting_revision, NULL, NULL FROM iterations WHERE id = ${me}`,
|
|
141
|
+
`SELECT 'Iterations', 'iteration', id, NULL, NULL, starting_revision, NULL, NULL FROM iterations WHERE model_id IN ${us}`,
|
|
142
|
+
`SELECT 'Results', 'result', result_id, role, commit_id, NULL, document, NULL FROM result_models WHERE model_id IN ${us}`,
|
|
143
|
+
`SELECT 'Results', 'result', result_id, NULL, NULL, revision_used, document, NULL FROM result_tools WHERE tool_id IN ${us}`,
|
|
144
|
+
`SELECT 'Results', 'result', result_id, role, NULL, NULL, document, ${dataDoc} FROM result_data d WHERE data_id IN ${us}`,
|
|
145
|
+
`SELECT 'Origin', 'result', ${origin}, NULL, NULL, NULL, NULL, NULL FROM data_objects WHERE id = ${me} AND ${origin} IS NOT NULL`,
|
|
146
|
+
`SELECT 'Links', kind, target_id, relation, NULL, NULL, document, NULL FROM memory_links WHERE memory_id = ${me}`,
|
|
147
|
+
`SELECT 'Linked from Memory', 'memory', memory_id, relation, NULL, NULL, NULL, NULL FROM memory_links WHERE target_id = ${me} OR (target_id IN ${us} AND kind = ${quote(doc?.kind ?? '')})`,
|
|
148
|
+
].join(' UNION ALL ');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The IDs a reference or a list of references names, in any shape the Kernel accepts.
|
|
152
|
+
function referencedIds(value) {
|
|
153
|
+
if (typeof value === 'string') return [value];
|
|
154
|
+
if (Array.isArray(value)) return value.flatMap(referencedIds);
|
|
155
|
+
if (value === null || typeof value !== 'object') return [];
|
|
156
|
+
const own = value.data_id ?? value.id ?? value.data;
|
|
157
|
+
return typeof own === 'string' ? [own] : Object.values(value).flatMap(referencedIds);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// result_data.role is the author's role, so "forcing" hides whether it was an input.
|
|
161
|
+
// The Result's own document still says.
|
|
162
|
+
function direction(row) {
|
|
163
|
+
if (!row.result_doc) return undefined;
|
|
164
|
+
const link = JSON.parse(row.link);
|
|
165
|
+
if (link.direction) return link.direction;
|
|
166
|
+
const result = JSON.parse(row.result_doc);
|
|
167
|
+
const [named] = referencedIds(link);
|
|
168
|
+
if (referencedIds(result.inputs).includes(named)) return 'input';
|
|
169
|
+
if (referencedIds(result.outputs).includes(named)) return 'output';
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function commitAvailable(checkout, commit) {
|
|
174
|
+
if (!checkout) return null; // Nothing on this host to look in.
|
|
175
|
+
if (!/^[0-9a-f]{4,64}$/i.test(commit)) return false;
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
execFile('git', ['--no-optional-locks', '-C', checkout, 'cat-file', '-e', `${commit}^{commit}`], { timeout: 5000 }, (err) => resolve(!err));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function relations(id, doc, configuration) {
|
|
182
|
+
if (!indexPresent) return null;
|
|
183
|
+
const models = configuration?.models ?? {};
|
|
184
|
+
const rows = await query(store.workspace.local_state.index, relationSql(id, doc));
|
|
185
|
+
return Promise.all(
|
|
186
|
+
rows.map(async (row) => {
|
|
187
|
+
const target = row.target === null ? null : store.byId.get(row.target) ?? store.byName.get(`${row.kind}:${row.target}`);
|
|
188
|
+
const flow = direction(row);
|
|
189
|
+
const relation = {
|
|
190
|
+
section: row.section === 'Data' && flow ? `${flow[0].toUpperCase()}${flow.slice(1)} Data` : row.section,
|
|
191
|
+
kind: target?.kind ?? row.kind,
|
|
192
|
+
id: target?.id ?? row.target,
|
|
193
|
+
name: target?.name,
|
|
194
|
+
present: Boolean(target),
|
|
195
|
+
direction: row.section === 'Results' ? flow : undefined,
|
|
196
|
+
role: row.role ?? undefined,
|
|
197
|
+
commit: row.commit_id ?? undefined,
|
|
198
|
+
revision: row.revision ?? undefined,
|
|
199
|
+
};
|
|
200
|
+
if (row.target === null) relation.detail = row.link; // A reference with no ID, such as a bare path.
|
|
201
|
+
if (row.section === 'Models used' && row.commit_id) {
|
|
202
|
+
relation.commit_available = await commitAvailable(models[target?.id ?? row.target]?.checkout, row.commit_id);
|
|
203
|
+
}
|
|
204
|
+
return relation;
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The local paths this host binds to an object, if any.
|
|
210
|
+
function hostBinding(doc, configuration) {
|
|
211
|
+
const section = { model: 'models', iteration: 'iterations', tool: 'tools' }[doc?.kind];
|
|
212
|
+
return section ? configuration?.[section]?.[doc.id] : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function object(id) {
|
|
216
|
+
if (!id || id.length > 1024 || id.includes('\0')) return undefined;
|
|
217
|
+
const doc = store.byId.get(id);
|
|
218
|
+
const bytes = doc ? await readFile(doc.path).catch(() => null) : null;
|
|
219
|
+
// Asked each time, because registering or binding a Model changes the host's bindings.
|
|
220
|
+
const { configuration } = await resolveWorkspace(store.root);
|
|
221
|
+
let rows = null;
|
|
222
|
+
let relationsError;
|
|
223
|
+
try {
|
|
224
|
+
rows = await relations(id, doc, configuration);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
relationsError = err.message;
|
|
227
|
+
}
|
|
228
|
+
const base = { id, relations: rows, relations_error: relationsError };
|
|
229
|
+
if (!bytes) return { ...base, present: false };
|
|
230
|
+
const indexRevision = indexRevisions.get(doc.path);
|
|
231
|
+
return {
|
|
232
|
+
...base,
|
|
233
|
+
present: true,
|
|
234
|
+
kind: doc.kind,
|
|
235
|
+
name: doc.name,
|
|
236
|
+
status: doc.status,
|
|
237
|
+
rel: doc.rel,
|
|
238
|
+
problem: doc.problem,
|
|
239
|
+
created_at: doc.created_at,
|
|
240
|
+
updated_at: doc.updated_at,
|
|
241
|
+
authored_at: doc.authored_at,
|
|
242
|
+
revision: revisionOf(bytes),
|
|
243
|
+
index_revision: indexRevision,
|
|
244
|
+
in_index: indexPresent ? indexRevision !== undefined : undefined,
|
|
245
|
+
binding: hostBinding(doc, configuration),
|
|
246
|
+
text: bytes.toString('utf8'),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// rest is untrusted: it selects among scanned documents, and is never used as a path.
|
|
251
|
+
export async function read(rest) {
|
|
252
|
+
if (!store) return undefined;
|
|
253
|
+
const [verb, ...tail] = rest.split('/');
|
|
254
|
+
if (verb === 'object') return object(tail.join('/'));
|
|
255
|
+
if (verb === 'list' && KINDS.includes(tail[0])) {
|
|
256
|
+
const offset = Math.max(0, Number.parseInt(tail[1], 10) || 0);
|
|
257
|
+
const limit = Math.min(MAX_PAGE, Math.max(1, Number.parseInt(tail[2], 10) || 100));
|
|
258
|
+
return list(tail[0], offset, limit);
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// What is running under this Workspace, from ps and lsof, and the files it is writing.
|
|
2
|
+
// Only what the process table and the files show; no opinion on whether it is healthy.
|
|
3
|
+
import { open, stat } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { extname, join } from 'node:path';
|
|
6
|
+
import { listProcesses, openFiles, workingDirectories } from '../processes.mjs';
|
|
7
|
+
import { inScope, readScope } from '../scope.mjs';
|
|
8
|
+
|
|
9
|
+
const POLL_MS = 4000;
|
|
10
|
+
const SCOPE_MS = 30000;
|
|
11
|
+
const CWD_REFRESH_MS = 30000; // a shell can cd; a new pid is looked up as soon as it appears
|
|
12
|
+
const TAIL_BYTES = 16 * 1024;
|
|
13
|
+
const TAIL_LINES = 5;
|
|
14
|
+
const LINE_CHARS = 400;
|
|
15
|
+
const MAX_FILES_PER_PROCESS = 8;
|
|
16
|
+
const GROWTH_WINDOW_MS = 60000;
|
|
17
|
+
const NOT_WORK = /^(?:\/System\/|\/usr\/libexec\/|\/usr\/sbin\/)/;
|
|
18
|
+
const SHELL = /^-?(?:\S*\/)?(?:zsh|bash|sh|dash|fish|ksh|tcsh|csh)$/;
|
|
19
|
+
const TMUX = /^(?:\S*\/)?tmux(?::|\s|$)/;
|
|
20
|
+
const UI_SERVER = /\bbin\/accrete-ui\.mjs(?:\s|$)/;
|
|
21
|
+
const BINARY_EXTENSIONS = new Set(['.nc', '.nc4', '.h5', '.hdf5', '.npy', '.npz', '.grib', '.grb', '.zarr', '.parquet', '.bin', '.dat', '.pkl', '.pt', '.ckpt', '.sqlite', '.db', '.png', '.jpg', '.pdf', '.gz', '.zip', '.tar', '.zst']);
|
|
22
|
+
|
|
23
|
+
function isShell(command) {
|
|
24
|
+
return SHELL.test(command.split(/\s+/, 1)[0]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A shell sitting at its prompt: no script and no -c, just options.
|
|
28
|
+
function isLoginShellForm(command) {
|
|
29
|
+
const [program, ...args] = command.split(/\s+/);
|
|
30
|
+
return SHELL.test(program) && args.every((arg) => arg.startsWith('-') && !/^-[a-z]*c[a-z]*$/.test(arg));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The tree of processes to show, from ps rows and pid -> cwd. A process's parent in
|
|
34
|
+
// the tree is its nearest ancestor that is itself shown.
|
|
35
|
+
export function selectProcesses(rows, cwds, scope, selfPid = process.pid) {
|
|
36
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
37
|
+
const servers = new Set(rows.filter((row) => row.pid === selfPid || UI_SERVER.test(row.command)).map((row) => row.pid));
|
|
38
|
+
const isOurs = (row) =>
|
|
39
|
+
servers.has(row.pid) || TMUX.test(row.command) || (servers.has(row.ppid) && !isShell(row.command)); // ps, lsof and the Kernel, run by a reader
|
|
40
|
+
|
|
41
|
+
const nodes = new Map();
|
|
42
|
+
for (const row of rows) {
|
|
43
|
+
if (inScope(scope, cwds.get(row.pid)) && !isOurs(row)) nodes.set(row.pid, { ...row, cwd: cwds.get(row.pid), children: [] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const roots = [];
|
|
47
|
+
for (const node of nodes.values()) {
|
|
48
|
+
let up = byPid.get(node.ppid);
|
|
49
|
+
while (up && !nodes.has(up.pid)) up = byPid.get(up.ppid);
|
|
50
|
+
(up ? nodes.get(up.pid).children : roots).push(node);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const keep = (node) => {
|
|
54
|
+
node.children = node.children.filter(keep).sort((a, b) => a.startedAt - b.startedAt);
|
|
55
|
+
return node.children.length > 0 || !isLoginShellForm(node.command) || node.cpu >= 1;
|
|
56
|
+
};
|
|
57
|
+
return roots.filter(keep).sort((a, b) => a.startedAt - b.startedAt);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function writtenFiles(files, home = homedir()) {
|
|
61
|
+
const agentState = [join(home, '.claude/'), join(home, '.codex/')];
|
|
62
|
+
return files.filter(
|
|
63
|
+
(file) =>
|
|
64
|
+
/^\d+$/.test(file.fd) &&
|
|
65
|
+
(file.access === 'w' || file.access === 'u') &&
|
|
66
|
+
file.type === 'REG' &&
|
|
67
|
+
file.name?.startsWith('/') &&
|
|
68
|
+
!agentState.some((dir) => file.name.startsWith(dir)),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Terminal control sequences go, and a line rewritten with \r shows its last state.
|
|
73
|
+
function cleanLine(line) {
|
|
74
|
+
const text = line
|
|
75
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
76
|
+
.split('\r')
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.pop() ?? '';
|
|
79
|
+
const plain = text.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
80
|
+
return plain.length > LINE_CHARS ? `${plain.slice(0, LINE_CHARS)}...` : plain;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The last lines of a file if its end looks like text, or { binary: true }.
|
|
84
|
+
export async function readLogTail(path, size) {
|
|
85
|
+
if (BINARY_EXTENSIONS.has(extname(path).toLowerCase())) return { binary: true };
|
|
86
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
87
|
+
const file = await open(path, 'r');
|
|
88
|
+
let buffer;
|
|
89
|
+
try {
|
|
90
|
+
buffer = Buffer.alloc(length);
|
|
91
|
+
const { bytesRead } = await file.read(buffer, 0, length, size - length);
|
|
92
|
+
buffer = buffer.subarray(0, bytesRead);
|
|
93
|
+
} finally {
|
|
94
|
+
await file.close();
|
|
95
|
+
}
|
|
96
|
+
if (buffer.includes(0)) return { binary: true };
|
|
97
|
+
const text = buffer.toString('utf8');
|
|
98
|
+
const odd = text.match(/[�\x01-\x08\x0e-\x1a\x1c-\x1f]/g)?.length ?? 0;
|
|
99
|
+
if (odd > Math.max(4, text.length / 100)) return { binary: true };
|
|
100
|
+
const lines = text.split('\n');
|
|
101
|
+
if (size > length) lines.shift(); // cut at the start
|
|
102
|
+
while (lines.length > 0 && !lines.at(-1).trim()) lines.pop();
|
|
103
|
+
return { lines: lines.slice(-TAIL_LINES).map(cleanLine) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const quiet = (node) => node.cpu < 1 && node.writes.length === 0 && node.children.every(quiet);
|
|
107
|
+
|
|
108
|
+
// An agent can keep dozens of the same idle helper here, one set per thread. Three or
|
|
109
|
+
// more identical quiet siblings read as one line, so a lone stuck program never folds.
|
|
110
|
+
export function foldIdentical(nodes) {
|
|
111
|
+
const same = new Map();
|
|
112
|
+
for (const node of nodes) {
|
|
113
|
+
if (!quiet(node)) continue;
|
|
114
|
+
if (!same.has(node.command)) same.set(node.command, []);
|
|
115
|
+
same.get(node.command).push(node);
|
|
116
|
+
}
|
|
117
|
+
const folded = [];
|
|
118
|
+
for (const node of nodes) {
|
|
119
|
+
const peers = same.get(node.command);
|
|
120
|
+
if (!quiet(node) || !peers || peers.length < 3) folded.push({ ...node, children: foldIdentical(node.children) });
|
|
121
|
+
else if (peers[0] === node) folded.push({ ...node, children: [], pids: peers.map((peer) => peer.pid), rss: peers.reduce((sum, peer) => sum + peer.rss, 0) });
|
|
122
|
+
}
|
|
123
|
+
return folded;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function present(node) {
|
|
127
|
+
return {
|
|
128
|
+
pid: node.pid,
|
|
129
|
+
pids: node.pids,
|
|
130
|
+
startedAt: node.startedAt,
|
|
131
|
+
cpu: Math.round(node.cpu),
|
|
132
|
+
rssMb: Math.round(node.rss / 1048576),
|
|
133
|
+
cwd: node.cwd,
|
|
134
|
+
command: node.command,
|
|
135
|
+
writes: node.writes,
|
|
136
|
+
children: node.children.map(present),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createRunning({ selfPid = process.pid, uid = process.getuid(), home = homedir() } = {}) {
|
|
141
|
+
const cwdCache = new Map(); // pid -> { startedAt, cwd }
|
|
142
|
+
const fileCache = new Map(); // path -> { size, mtimeMs, samples, tail }
|
|
143
|
+
let cwdsAt = 0;
|
|
144
|
+
|
|
145
|
+
async function refreshCwds(rows, now) {
|
|
146
|
+
const candidates = rows.filter((row) => row.uid === uid && !NOT_WORK.test(row.command));
|
|
147
|
+
const stale = now - cwdsAt >= CWD_REFRESH_MS;
|
|
148
|
+
const lookup = stale ? candidates : candidates.filter((row) => cwdCache.get(row.pid)?.startedAt !== row.startedAt);
|
|
149
|
+
if (stale) cwdsAt = now;
|
|
150
|
+
const found = await workingDirectories(lookup.map((row) => row.pid));
|
|
151
|
+
for (const row of lookup) cwdCache.set(row.pid, { startedAt: row.startedAt, cwd: found.get(row.pid) ?? null });
|
|
152
|
+
const alive = new Set(rows.map((row) => row.pid));
|
|
153
|
+
for (const pid of cwdCache.keys()) if (!alive.has(pid)) cwdCache.delete(pid);
|
|
154
|
+
return new Map(candidates.map((row) => [row.pid, cwdCache.get(row.pid)?.cwd]));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function describeFile(path, now) {
|
|
158
|
+
let info;
|
|
159
|
+
try {
|
|
160
|
+
info = await stat(path);
|
|
161
|
+
} catch {
|
|
162
|
+
return null; // deleted while still open, or out of reach
|
|
163
|
+
}
|
|
164
|
+
if (!info.isFile()) return null;
|
|
165
|
+
let known = fileCache.get(path);
|
|
166
|
+
if (!known) fileCache.set(path, (known = { samples: [], tail: null }));
|
|
167
|
+
if (known.size !== info.size || known.mtimeMs !== info.mtimeMs || !known.tail) {
|
|
168
|
+
known.size = info.size;
|
|
169
|
+
known.mtimeMs = info.mtimeMs;
|
|
170
|
+
known.tail = await readLogTail(path, info.size).catch(() => ({ unknown: true }));
|
|
171
|
+
}
|
|
172
|
+
known.seen = now;
|
|
173
|
+
known.samples.push({ at: now, size: info.size });
|
|
174
|
+
while (known.samples.length > 2 && now - known.samples[1].at >= GROWTH_WINDOW_MS) known.samples.shift();
|
|
175
|
+
const first = known.samples[0];
|
|
176
|
+
return {
|
|
177
|
+
path,
|
|
178
|
+
size: info.size,
|
|
179
|
+
mtimeMs: info.mtimeMs,
|
|
180
|
+
...known.tail,
|
|
181
|
+
grew: info.size - first.size,
|
|
182
|
+
overSeconds: Math.round((now - first.at) / 5000) * 5,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function poll(scope) {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const rows = await listProcesses();
|
|
189
|
+
const cwds = await refreshCwds(rows, now);
|
|
190
|
+
const roots = selectProcesses(rows, cwds, scope, selfPid);
|
|
191
|
+
|
|
192
|
+
const shown = [];
|
|
193
|
+
const walk = (node) => {
|
|
194
|
+
shown.push(node);
|
|
195
|
+
node.children.forEach(walk);
|
|
196
|
+
};
|
|
197
|
+
roots.forEach(walk);
|
|
198
|
+
|
|
199
|
+
const opened = await openFiles(shown.map((node) => node.pid));
|
|
200
|
+
const files = {};
|
|
201
|
+
for (const node of shown) {
|
|
202
|
+
const written = writtenFiles(opened.get(node.pid) ?? [], home);
|
|
203
|
+
const paths = [...new Set(written.map((file) => file.name))];
|
|
204
|
+
for (const path of paths) {
|
|
205
|
+
if (!(path in files)) files[path] = await describeFile(path, now);
|
|
206
|
+
}
|
|
207
|
+
node.writes = paths
|
|
208
|
+
.filter((path) => files[path])
|
|
209
|
+
.sort((a, b) => files[b].mtimeMs - files[a].mtimeMs)
|
|
210
|
+
.slice(0, MAX_FILES_PER_PROCESS);
|
|
211
|
+
}
|
|
212
|
+
for (const [path, known] of fileCache) if (known.seen !== now) fileCache.delete(path);
|
|
213
|
+
for (const path of Object.keys(files)) if (!files[path]) delete files[path];
|
|
214
|
+
|
|
215
|
+
return { scope, home, roots: foldIdentical(roots).map(present), files };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { poll };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function start({ root, emit }) {
|
|
222
|
+
emit('running', { scope: [root], home: homedir(), roots: [], files: {}, loading: true });
|
|
223
|
+
const running = createRunning();
|
|
224
|
+
let scope = [root];
|
|
225
|
+
let scopeAt = 0;
|
|
226
|
+
let last = null;
|
|
227
|
+
|
|
228
|
+
async function poll() {
|
|
229
|
+
try {
|
|
230
|
+
if (Date.now() - scopeAt > SCOPE_MS) {
|
|
231
|
+
scope = await readScope(root);
|
|
232
|
+
scopeAt = Date.now();
|
|
233
|
+
}
|
|
234
|
+
const state = JSON.stringify(await running.poll(scope));
|
|
235
|
+
if (state !== last) {
|
|
236
|
+
last = state;
|
|
237
|
+
emit('running', JSON.parse(state));
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error(`accrete-ui: running poll failed: ${err.message}`);
|
|
241
|
+
}
|
|
242
|
+
setTimeout(poll, POLL_MS).unref();
|
|
243
|
+
}
|
|
244
|
+
poll();
|
|
245
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which directories count as "this Workspace": its root, bound Model checkouts,
|
|
2
|
+
// and Iteration worktrees. The Kernel keeps worktree bindings in local configuration,
|
|
3
|
+
// so `config resolve` reports them; an Iteration document may also name one.
|
|
4
|
+
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { resolveWorkspace } from './workspace.mjs';
|
|
7
|
+
|
|
8
|
+
export async function readScope(root) {
|
|
9
|
+
const workspace = await resolveWorkspace(root);
|
|
10
|
+
const { configuration: config, paths } = workspace;
|
|
11
|
+
const found = [workspace.project_root ?? root];
|
|
12
|
+
for (const model of Object.values(config?.models ?? {})) found.push(model?.checkout);
|
|
13
|
+
for (const iteration of Object.values(config?.iterations ?? {})) found.push(iteration?.worktree);
|
|
14
|
+
|
|
15
|
+
const iterations = join(paths?.metadata ?? join(root, '.accrete/objects'), 'iterations');
|
|
16
|
+
const names = await readdir(iterations).catch(() => []);
|
|
17
|
+
for (const name of names.filter((n) => n.endsWith('.json'))) {
|
|
18
|
+
try {
|
|
19
|
+
found.push(JSON.parse(await readFile(join(iterations, name), 'utf8')).worktree);
|
|
20
|
+
} catch {
|
|
21
|
+
// A document being replaced or not valid JSON names no worktree for now.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const scope = new Set();
|
|
26
|
+
for (const path of found) {
|
|
27
|
+
if (typeof path !== 'string' || !path.startsWith('/')) continue;
|
|
28
|
+
scope.add(path.replace(/\/+$/, ''));
|
|
29
|
+
scope.add(await realpath(path).catch(() => path));
|
|
30
|
+
}
|
|
31
|
+
return [...scope];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function inScope(scope, path) {
|
|
35
|
+
return typeof path === 'string' && scope.some((dir) => path === dir || path.startsWith(`${dir}/`));
|
|
36
|
+
}
|