reslop 0.2.7 → 0.3.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 +30 -1
- package/README.md +16 -14
- package/bin/reslop.js +8 -5
- package/lib/agents/agents.js +573 -0
- package/lib/agents/allow.js +71 -0
- package/lib/agents/allow.json +17 -0
- package/lib/agents/models.js +467 -0
- package/lib/api.js +15 -12
- package/lib/cli.js +43 -68
- package/lib/{files.js → common/files.js} +48 -55
- package/lib/{find.js → common/find.js} +1 -1
- package/lib/common/format.js +101 -0
- package/lib/{utilities.js → common/process.js} +7 -95
- package/lib/common/tasks.json +27 -0
- package/lib/common/utilities.js +112 -0
- package/lib/dashboard/git.js +9 -16
- package/lib/dashboard/model.js +90 -49
- package/lib/dashboard/npm.js +17 -13
- package/lib/dashboard/tree.js +27 -30
- package/lib/{session → dashboard}/watch.js +36 -31
- package/lib/{workspace.js → dashboard/workspace.js} +23 -38
- package/lib/{deps.js → deps/deps.js} +31 -45
- package/lib/{deps-diff.js → deps/diff.js} +35 -25
- package/lib/{manifest.js → deps/manifest.js} +47 -30
- package/lib/diff/align.js +1 -2
- package/lib/diff/diff.js +2 -2
- package/lib/diff/display.js +29 -5
- package/lib/diff/inline.js +10 -14
- package/lib/diff/items.js +6 -4
- package/lib/diff/patch.js +39 -3
- package/lib/diff/unit.js +45 -16
- package/lib/{git-branches.js → git/branches.js} +53 -48
- package/lib/{git-deps.js → git/deps.js} +151 -95
- package/lib/git/git.js +71 -0
- package/lib/git/worktree.js +504 -0
- package/lib/highlight/bash.js +1 -2
- package/lib/highlight/csv.js +1 -2
- package/lib/highlight/dot.js +1 -2
- package/lib/{highlight.js → highlight/highlight.js} +42 -11
- package/lib/highlight/html.js +20 -18
- package/lib/highlight/json.js +1 -2
- package/lib/highlight/log.js +1 -2
- package/lib/highlight/txt.js +1 -2
- package/lib/{session → input}/actions.js +114 -48
- package/lib/{editor.js → input/editor.js} +16 -8
- package/lib/{keys.js → input/keys.js} +24 -45
- package/lib/input/line-edit.js +78 -0
- package/lib/input/menu-pick.js +70 -0
- package/lib/render/agents.js +642 -0
- package/lib/render/branches.js +4 -5
- package/lib/render/commits.js +54 -64
- package/lib/render/{dash-activity.js → dashboard/activity.js} +25 -40
- package/lib/render/{dash-blocks.js → dashboard/blocks.js} +131 -137
- package/lib/render/{dashboard.js → dashboard/dashboard.js} +25 -22
- package/lib/render/diff.js +166 -45
- package/lib/render/field.js +131 -0
- package/lib/render/files.js +9 -14
- package/lib/render/footer.js +385 -0
- package/lib/render/frame.js +123 -25
- package/lib/render/header.js +176 -0
- package/lib/render/log.js +320 -0
- package/lib/render/menu.js +60 -0
- package/lib/render/notes.js +16 -11
- package/lib/render/npm.js +322 -196
- package/lib/render/packages.js +9 -13
- package/lib/render/primitives.js +101 -15
- package/lib/render/render.js +7 -11
- package/lib/render/repos.js +13 -25
- package/lib/render/status.js +370 -0
- package/lib/render/status.json +85 -0
- package/lib/render/{dash-table.js → table.js} +13 -60
- package/lib/render/tiles.js +109 -48
- package/lib/render/unit.js +32 -13
- package/lib/{report-model.js → report/model.js} +4 -6
- package/lib/{report-parse.js → report/parse.js} +9 -14
- package/lib/{report-render.js → report/render.js} +48 -19
- package/lib/{report-run.js → report/run.js} +42 -30
- package/lib/{report-test.js → report/test.js} +56 -55
- package/lib/{report-tools.js → report/tools.js} +24 -33
- package/lib/review/agent-instructions.md +14 -0
- package/lib/review/format.js +420 -0
- package/lib/{review-merge.js → review/merge.js} +9 -8
- package/lib/review/review.js +190 -0
- package/lib/review/store.js +281 -0
- package/lib/review/templates.js +83 -0
- package/lib/runs/commands.js +14 -0
- package/lib/runs/logs.js +299 -0
- package/lib/runs/output.js +174 -0
- package/lib/runs/process.js +113 -0
- package/lib/{runs.js → runs/runs.js} +84 -36
- package/lib/runs/scripts.js +150 -0
- package/lib/session/agents/agents.js +976 -0
- package/lib/session/agents/job.js +320 -0
- package/lib/session/agents/jobs.js +355 -0
- package/lib/session/agents/options.js +153 -0
- package/lib/session/agents/plans.js +83 -0
- package/lib/session/branches.js +33 -40
- package/lib/session/change-actions.js +34 -100
- package/lib/session/commit.js +91 -52
- package/lib/session/compose.js +189 -147
- package/lib/session/dashboard.js +23 -22
- package/lib/session/dispatch.js +86 -21
- package/lib/session/files-pane.js +47 -26
- package/lib/session/find.js +16 -29
- package/lib/session/import.js +93 -0
- package/lib/session/items.js +8 -17
- package/lib/session/lifecycle.js +78 -72
- package/lib/session/load.js +2 -4
- package/lib/session/navigation.js +6 -3
- package/lib/session/npm/history.js +250 -0
- package/lib/session/npm/npm.js +836 -0
- package/lib/session/{packages.js → npm/packages.js} +21 -14
- package/lib/session/ops.js +125 -34
- package/lib/session/pointer.js +202 -10
- package/lib/session/prefs.js +112 -0
- package/lib/session/reads.js +109 -0
- package/lib/session/review.js +28 -10
- package/lib/session/run-panel.js +40 -0
- package/lib/{session.js → session/session.js} +158 -63
- package/lib/session/tasks.js +321 -66
- package/lib/session/templates.js +1 -1
- package/lib/session/terminal.js +94 -12
- package/lib/session/unit.js +35 -30
- package/lib/session/update.js +194 -6
- package/lib/session/view.js +65 -65
- package/lib/session/workspace.js +39 -78
- package/lib/{capabilities.js → source/capabilities.js} +3 -12
- package/lib/{github.js → source/github.js} +102 -32
- package/lib/{gitlab.js → source/gitlab.js} +87 -10
- package/lib/{remote.js → source/remote.js} +144 -40
- package/lib/source/review-threads.graphql +20 -0
- package/lib/source/source.js +74 -0
- package/lib/term/ansi.js +396 -0
- package/lib/{clipboard.js → term/clipboard.js} +3 -6
- package/lib/{select.js → term/select.js} +4 -3
- package/lib/term/themes/dark.json +122 -0
- package/lib/term/themes/light.json +122 -0
- package/lib/term/wide.txt +21 -0
- package/lib/{wrap.js → term/wrap.js} +4 -4
- package/package.json +2 -2
- package/lib/ansi.js +0 -545
- package/lib/detect.js +0 -34
- package/lib/diff/edit.js +0 -33
- package/lib/git-worktree.js +0 -455
- package/lib/git.js +0 -79
- package/lib/npm-commands.js +0 -522
- package/lib/render/chrome.js +0 -806
- package/lib/render/present.js +0 -19
- package/lib/render/report.js +0 -43
- package/lib/render/search.js +0 -25
- package/lib/review.js +0 -745
- package/lib/session/accessors.js +0 -26
- package/lib/session/edit-click.js +0 -57
- package/lib/session/npm.js +0 -452
- package/lib/session/progress.js +0 -41
- package/lib/source.js +0 -36
- package/lib/update.js +0 -192
- /package/lib/{git-reword-todo.js → git/reword-todo.js} +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { isHashObject } = require('metautil');
|
|
5
|
+
const { pad2, withNotice, readText } = require('../../common/utilities.js');
|
|
6
|
+
const { LOG_DIR } = require('../../common/files.js');
|
|
7
|
+
const { agentSpec } = require('../../agents/agents.js');
|
|
8
|
+
|
|
9
|
+
const RUN_KEEP = 40;
|
|
10
|
+
|
|
11
|
+
const OUTPUT_KEEP = 100000;
|
|
12
|
+
|
|
13
|
+
const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
|
|
14
|
+
|
|
15
|
+
const SESSION_FLAG = '--(?:resume|conversation)';
|
|
16
|
+
|
|
17
|
+
const SESSION_MARK = new RegExp(`${SESSION_FLAG}[= ](${UUID})`, 'gi');
|
|
18
|
+
|
|
19
|
+
const FILES_MARK = /(\d[\d,]*)\s+files?\s+(?:edited|changed|modified)/gi;
|
|
20
|
+
|
|
21
|
+
const tokenPattern = (name) => {
|
|
22
|
+
const word = `${name}[_ -]?tokens?`;
|
|
23
|
+
const json = `"${name}_tokens"`;
|
|
24
|
+
const body = `(?:${word}|${json})\\s*[:=]\\s*"?(\\d[\\d,]*)`;
|
|
25
|
+
return new RegExp(body, 'gi');
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const TOTAL_TOKEN = tokenPattern('total');
|
|
29
|
+
|
|
30
|
+
const INPUT_TOKEN = tokenPattern('input');
|
|
31
|
+
|
|
32
|
+
const OUTPUT_TOKEN = tokenPattern('output');
|
|
33
|
+
|
|
34
|
+
const exitLabel = (status) => {
|
|
35
|
+
if (status === null || status === undefined || status === '') {
|
|
36
|
+
return 'stopped';
|
|
37
|
+
}
|
|
38
|
+
return `exit ${status}`;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const elapsedSeconds = (job, now = Date.now()) => {
|
|
42
|
+
const start = Number(job && job.startedAt) || 0;
|
|
43
|
+
if (!start) return 0;
|
|
44
|
+
const end = job.status === 'running' ? now : Number(job.endedAt) || now;
|
|
45
|
+
return Math.max(0, Math.floor((end - start) / 1000));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const elapsedLabel = (job, now = Date.now()) => {
|
|
49
|
+
const start = Number(job && job.startedAt) || 0;
|
|
50
|
+
if (!start) return '';
|
|
51
|
+
const sec = elapsedSeconds(job, now);
|
|
52
|
+
const hours = Math.floor(sec / 3600);
|
|
53
|
+
const mins = Math.floor((sec % 3600) / 60);
|
|
54
|
+
const secs = pad2(sec % 60);
|
|
55
|
+
if (hours) return `${hours}:${pad2(mins)}:${secs}`;
|
|
56
|
+
return `${mins}:${secs}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const textOf = (value, fallback = '') => `${value ?? fallback}`;
|
|
60
|
+
|
|
61
|
+
const countOf = (value) => {
|
|
62
|
+
const raw = `${value ?? ''}`.replaceAll(',', '');
|
|
63
|
+
const number = Number(raw);
|
|
64
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
65
|
+
return Math.floor(number);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const countField = (value) => {
|
|
69
|
+
if (value === undefined || value === null || value === '') return null;
|
|
70
|
+
return countOf(value);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const lastCount = (text, re) => {
|
|
74
|
+
re.lastIndex = 0;
|
|
75
|
+
let found = null;
|
|
76
|
+
for (const match of `${text ?? ''}`.matchAll(re)) {
|
|
77
|
+
const count = countOf(match[1]);
|
|
78
|
+
if (count !== null) found = count;
|
|
79
|
+
}
|
|
80
|
+
return found;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const sessionOf = (text) => {
|
|
84
|
+
SESSION_MARK.lastIndex = 0;
|
|
85
|
+
let found = '';
|
|
86
|
+
for (const match of `${text ?? ''}`.matchAll(SESSION_MARK)) found = match[1];
|
|
87
|
+
return found;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const tokensOf = (text) => {
|
|
91
|
+
const total = lastCount(text, TOTAL_TOKEN);
|
|
92
|
+
if (total !== null) return total;
|
|
93
|
+
const input = lastCount(text, INPUT_TOKEN);
|
|
94
|
+
const output = lastCount(text, OUTPUT_TOKEN);
|
|
95
|
+
if (input === null && output === null) return null;
|
|
96
|
+
return (input ?? 0) + (output ?? 0);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const runStats = (text) => {
|
|
100
|
+
const stats = {};
|
|
101
|
+
const session = sessionOf(text);
|
|
102
|
+
const files = lastCount(text, FILES_MARK);
|
|
103
|
+
const tokens = tokensOf(text);
|
|
104
|
+
if (session) stats.session = session;
|
|
105
|
+
if (files !== null) stats.files = files;
|
|
106
|
+
if (tokens !== null) stats.tokens = tokens;
|
|
107
|
+
return stats;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const restoredState = (item) => {
|
|
111
|
+
if (item.running === true || item.status === 'running') {
|
|
112
|
+
return { status: 'running', exit: null };
|
|
113
|
+
}
|
|
114
|
+
if (item.stopped === true || item.status === 'stopped') {
|
|
115
|
+
return { status: 'stopped', exit: null };
|
|
116
|
+
}
|
|
117
|
+
if (typeof item.exit === 'number') {
|
|
118
|
+
return { status: `exit ${item.exit}`, exit: item.exit };
|
|
119
|
+
}
|
|
120
|
+
const status = textOf(item.status);
|
|
121
|
+
const code = status.startsWith('exit ') ? Number(status.slice(5)) : NaN;
|
|
122
|
+
if (Number.isFinite(code)) return { status, exit: code };
|
|
123
|
+
return { status: status || 'stopped', exit: null };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const outcome = (job) => {
|
|
127
|
+
if (job.status === 'running') return { running: true };
|
|
128
|
+
if (typeof job.exit === 'number') return { exit: job.exit };
|
|
129
|
+
return { stopped: true };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const applyElapsed = (job, item) => {
|
|
133
|
+
if (job.endedAt || !job.startedAt) return;
|
|
134
|
+
const elapsed = Number(item.elapsed);
|
|
135
|
+
if (!Number.isFinite(elapsed)) return;
|
|
136
|
+
job.endedAt = job.startedAt + elapsed * 1000;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const readLog = (root, name) =>
|
|
140
|
+
root && name ? readText(path.join(root, LOG_DIR, name)) : '';
|
|
141
|
+
|
|
142
|
+
// An agent with a denial mark exits 0 having done nothing: silent or refused
|
|
143
|
+
const fruitless = (job, text) => {
|
|
144
|
+
if (job.action !== 'run') return false;
|
|
145
|
+
const spec = agentSpec(job.cliId);
|
|
146
|
+
if (!spec || !spec.denied) return false;
|
|
147
|
+
return !text.trim() || spec.denied.test(text);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
class AgentJob {
|
|
151
|
+
constructor(fields) {
|
|
152
|
+
this.id = fields.id;
|
|
153
|
+
this.cliId = fields.cliId;
|
|
154
|
+
this.name = fields.name;
|
|
155
|
+
this.model = fields.model;
|
|
156
|
+
this.cmd = fields.cmd;
|
|
157
|
+
this.args = fields.args;
|
|
158
|
+
this.command = fields.command;
|
|
159
|
+
this.plan = fields.plan;
|
|
160
|
+
this.action = fields.action;
|
|
161
|
+
this.status = fields.status;
|
|
162
|
+
this.exit = fields.exit;
|
|
163
|
+
this.output = fields.output;
|
|
164
|
+
this.session = textOf(fields.session);
|
|
165
|
+
this.files = countField(fields.files);
|
|
166
|
+
this.tokens = countField(fields.tokens);
|
|
167
|
+
this.child = null;
|
|
168
|
+
this.startedAt = fields.startedAt;
|
|
169
|
+
this.endedAt = fields.endedAt;
|
|
170
|
+
this.stopping = false;
|
|
171
|
+
this.logName = textOf(fields.logName);
|
|
172
|
+
this.rawName = textOf(fields.rawName);
|
|
173
|
+
this.rawText = textOf(fields.rawText);
|
|
174
|
+
this.held = fields.held === true;
|
|
175
|
+
this.dirty = fields.dirty === true;
|
|
176
|
+
this.loggedAt = 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
static launch(id, launch, model) {
|
|
180
|
+
return new AgentJob({
|
|
181
|
+
id,
|
|
182
|
+
cliId: launch.id,
|
|
183
|
+
name: launch.name,
|
|
184
|
+
model,
|
|
185
|
+
cmd: launch.cmd,
|
|
186
|
+
args: launch.args,
|
|
187
|
+
command: launch.command,
|
|
188
|
+
plan: launch.plan,
|
|
189
|
+
action: launch.kind,
|
|
190
|
+
status: 'running',
|
|
191
|
+
exit: null,
|
|
192
|
+
output: '',
|
|
193
|
+
session: launch.session,
|
|
194
|
+
startedAt: Date.now(),
|
|
195
|
+
endedAt: 0,
|
|
196
|
+
dirty: true,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
static restore(item, fallbackId) {
|
|
201
|
+
const state = restoredState(item);
|
|
202
|
+
const inline = textOf(item.output);
|
|
203
|
+
const job = new AgentJob({
|
|
204
|
+
id: Number(item.id) || fallbackId,
|
|
205
|
+
cliId: textOf(item.cliId),
|
|
206
|
+
name: textOf(item.name),
|
|
207
|
+
model: textOf(item.model),
|
|
208
|
+
cmd: textOf(item.cmd),
|
|
209
|
+
args: Array.isArray(item.args) ? item.args : [],
|
|
210
|
+
command: textOf(item.command),
|
|
211
|
+
plan: textOf(item.plan),
|
|
212
|
+
action: textOf(item.action, 'run'),
|
|
213
|
+
status: state.status,
|
|
214
|
+
exit: state.exit,
|
|
215
|
+
output: inline,
|
|
216
|
+
logName: item.log,
|
|
217
|
+
rawName: item.raw,
|
|
218
|
+
held: !inline,
|
|
219
|
+
dirty: Boolean(inline),
|
|
220
|
+
session: item.session,
|
|
221
|
+
files: item.files,
|
|
222
|
+
tokens: item.tokens,
|
|
223
|
+
startedAt: Number(item.startedAt) || 0,
|
|
224
|
+
endedAt: Number(item.endedAt) || 0,
|
|
225
|
+
});
|
|
226
|
+
if (state.status !== 'running') applyElapsed(job, item);
|
|
227
|
+
if (job.status !== 'running') return job;
|
|
228
|
+
job.status = 'stopped';
|
|
229
|
+
job.output = withNotice(job.output, 'interrupted');
|
|
230
|
+
job.endedAt ||= job.startedAt || Date.now();
|
|
231
|
+
return job;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
elapsed(now = Date.now()) {
|
|
235
|
+
return elapsedLabel(this, now);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
captureStats() {
|
|
239
|
+
const stats = runStats(this.output);
|
|
240
|
+
if (stats.session) this.session = stats.session;
|
|
241
|
+
if (typeof stats.files === 'number') this.files = stats.files;
|
|
242
|
+
if (typeof stats.tokens === 'number') this.tokens = stats.tokens;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
syncRaw() {
|
|
246
|
+
const { child } = this;
|
|
247
|
+
if (child && typeof child.raw === 'function') this.rawText = child.raw();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
record() {
|
|
251
|
+
this.captureStats();
|
|
252
|
+
const saved = {
|
|
253
|
+
id: this.id,
|
|
254
|
+
cliId: this.cliId,
|
|
255
|
+
name: this.name,
|
|
256
|
+
model: this.model,
|
|
257
|
+
cmd: this.cmd,
|
|
258
|
+
args: this.args,
|
|
259
|
+
command: this.command,
|
|
260
|
+
plan: this.plan,
|
|
261
|
+
action: this.action,
|
|
262
|
+
...outcome(this),
|
|
263
|
+
elapsed: elapsedSeconds(this),
|
|
264
|
+
startedAt: this.startedAt,
|
|
265
|
+
};
|
|
266
|
+
if (this.logName) saved.log = this.logName;
|
|
267
|
+
if (this.rawName) saved.raw = this.rawName;
|
|
268
|
+
if (this.session) saved.session = this.session;
|
|
269
|
+
if (typeof this.files === 'number') saved.files = this.files;
|
|
270
|
+
return saved;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
close(status, exit, output) {
|
|
274
|
+
this.status = status;
|
|
275
|
+
this.exit = exit;
|
|
276
|
+
this.output = output;
|
|
277
|
+
this.endedAt = Date.now();
|
|
278
|
+
this.child = null;
|
|
279
|
+
this.stopping = false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
stop() {
|
|
283
|
+
this.close('stopped', null, withNotice(this.output, 'terminated'));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
finish(result) {
|
|
287
|
+
let text = result.text ?? '';
|
|
288
|
+
let { status } = result;
|
|
289
|
+
if (status === 0 && fruitless(this, text)) {
|
|
290
|
+
status = 1;
|
|
291
|
+
if (!text.trim()) text = withNotice(text, 'Agent exited without output');
|
|
292
|
+
}
|
|
293
|
+
this.close(exitLabel(status), status, text);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const launchJob = (id, launch, model) => AgentJob.launch(id, launch, model);
|
|
298
|
+
|
|
299
|
+
const restoreJobs = (saved) => {
|
|
300
|
+
const jobs = [];
|
|
301
|
+
let nextId = 1;
|
|
302
|
+
for (const item of Array.isArray(saved) ? saved : []) {
|
|
303
|
+
if (!isHashObject(item)) continue;
|
|
304
|
+
const job = AgentJob.restore(item, nextId);
|
|
305
|
+
nextId = Math.max(nextId, job.id + 1);
|
|
306
|
+
jobs.push(job);
|
|
307
|
+
}
|
|
308
|
+
return { jobs, nextId };
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
RUN_KEEP,
|
|
313
|
+
OUTPUT_KEEP,
|
|
314
|
+
elapsedLabel,
|
|
315
|
+
textOf,
|
|
316
|
+
runStats,
|
|
317
|
+
readLog,
|
|
318
|
+
launchJob,
|
|
319
|
+
restoreJobs,
|
|
320
|
+
};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { isHashObject } = require('metautil');
|
|
6
|
+
const { withNewline, listDir, readJson } = require('../../common/utilities.js');
|
|
7
|
+
const { logText, nextLogFile } = require('../../runs/logs.js');
|
|
8
|
+
const jobPart = require('./job.js');
|
|
9
|
+
const { OUTPUT_KEEP, readLog, textOf } = jobPart;
|
|
10
|
+
const { elapsedLabel, RUN_KEEP, restoreJobs, launchJob } = jobPart;
|
|
11
|
+
const { runStats } = jobPart;
|
|
12
|
+
const prefsPart = require('../prefs.js');
|
|
13
|
+
const { planPath, readPrefs, writePrefs } = prefsPart;
|
|
14
|
+
const { writeStored } = prefsPart;
|
|
15
|
+
const { LOG_DIR } = require('../../common/files.js');
|
|
16
|
+
|
|
17
|
+
const RUNS_FILE = '.runs';
|
|
18
|
+
|
|
19
|
+
const SESSIONS_FILE = '.sessions';
|
|
20
|
+
|
|
21
|
+
const AGENT_JSON = /-agent-\d+\.json$/;
|
|
22
|
+
|
|
23
|
+
const logNames = (root) => listDir(path.join(root, LOG_DIR));
|
|
24
|
+
|
|
25
|
+
const agentJsonNames = (root) =>
|
|
26
|
+
logNames(root)
|
|
27
|
+
.filter((name) => AGENT_JSON.test(name))
|
|
28
|
+
.sort();
|
|
29
|
+
|
|
30
|
+
const jsonNameOf = (job) =>
|
|
31
|
+
job.logName ? job.logName.replace(/\.log$/, '.json') : '';
|
|
32
|
+
|
|
33
|
+
const writeText = (file, text) => {
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, text);
|
|
37
|
+
} catch {
|
|
38
|
+
// the log directory can be read-only
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const assignLog = (root, job) => {
|
|
43
|
+
if (!root || job.logName) return;
|
|
44
|
+
const when = Number(job.startedAt) || Date.now();
|
|
45
|
+
try {
|
|
46
|
+
const slot = nextLogFile(root, 'agent', new Date(when));
|
|
47
|
+
job.logName = slot.name;
|
|
48
|
+
job.rawName = slot.rawName;
|
|
49
|
+
} catch {
|
|
50
|
+
// the log directory can be unusable
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const stemOf = (name) => {
|
|
55
|
+
const file = `${name ?? ''}`;
|
|
56
|
+
if (file.endsWith('.log') || file.endsWith('.raw')) {
|
|
57
|
+
return file.replace(/\.(?:log|raw)$/, '');
|
|
58
|
+
}
|
|
59
|
+
return file;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const writeJob = (root, job) => {
|
|
63
|
+
if (!job.logName) return;
|
|
64
|
+
const dir = path.join(root, LOG_DIR);
|
|
65
|
+
const record = job.record();
|
|
66
|
+
const json = `${JSON.stringify(record, null, 2)}\n`;
|
|
67
|
+
writeText(path.join(dir, jsonNameOf(job)), json);
|
|
68
|
+
if (job.held && !job.dirty) return;
|
|
69
|
+
job.syncRaw();
|
|
70
|
+
const plain = `${job.output ?? ''}`.slice(-OUTPUT_KEEP);
|
|
71
|
+
const source = job.rawText ? job.rawText : plain;
|
|
72
|
+
const clipped = `${source}`.slice(-OUTPUT_KEEP);
|
|
73
|
+
writeText(path.join(dir, job.logName), logText(plain));
|
|
74
|
+
writeText(path.join(dir, job.rawName), withNewline(clipped));
|
|
75
|
+
job.held = true;
|
|
76
|
+
job.dirty = false;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const readRunText = (root, job) => {
|
|
80
|
+
const raw = readLog(root, job && job.rawName);
|
|
81
|
+
if (raw) return raw;
|
|
82
|
+
const log = readLog(root, job && job.logName);
|
|
83
|
+
if (log) return log;
|
|
84
|
+
return textOf(job && job.output);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const sessionKey = (job) => textOf(job.session) || stemOf(job.logName);
|
|
88
|
+
|
|
89
|
+
const orderedSessions = (jobs, id) => {
|
|
90
|
+
const runs = jobs.filter((job) => sessionKey(job) === id);
|
|
91
|
+
runs.sort((left, right) => {
|
|
92
|
+
const at = (left.startedAt || 0) - (right.startedAt || 0);
|
|
93
|
+
if (at) return at;
|
|
94
|
+
return left.id - right.id;
|
|
95
|
+
});
|
|
96
|
+
return runs;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const runBlock = (root, job) => {
|
|
100
|
+
const stem = stemOf(job.logName);
|
|
101
|
+
const body = readRunText(root, job).replace(/\n$/, '');
|
|
102
|
+
if (!stem) return body;
|
|
103
|
+
if (!body) return stem;
|
|
104
|
+
return `${stem}\n${body}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const replayLaunch = (job) => ({
|
|
108
|
+
ok: true,
|
|
109
|
+
cmd: job.cmd,
|
|
110
|
+
args: job.args.slice(),
|
|
111
|
+
prompt: '',
|
|
112
|
+
plan: job.plan,
|
|
113
|
+
id: job.cliId,
|
|
114
|
+
name: job.name,
|
|
115
|
+
kind: job.action || 'run',
|
|
116
|
+
command: job.command,
|
|
117
|
+
session: job.session,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const pruneAgent = (root, jobs) => {
|
|
121
|
+
const keep = new Set();
|
|
122
|
+
for (const job of jobs) {
|
|
123
|
+
const name = jsonNameOf(job);
|
|
124
|
+
if (name) keep.add(name);
|
|
125
|
+
}
|
|
126
|
+
if (!keep.size) return;
|
|
127
|
+
const dir = path.join(root, LOG_DIR);
|
|
128
|
+
for (const name of agentJsonNames(root)) {
|
|
129
|
+
if (keep.has(name)) continue;
|
|
130
|
+
const stem = name.replace(/\.json$/, '');
|
|
131
|
+
for (const suffix of ['.json', '.log', '.raw']) {
|
|
132
|
+
fs.rmSync(path.join(dir, `${stem}${suffix}`), { force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const byStart = (left, right) => {
|
|
138
|
+
const start = (Number(left.startedAt) || 0) - (Number(right.startedAt) || 0);
|
|
139
|
+
if (start) return start;
|
|
140
|
+
return (Number(left.id) || 0) - (Number(right.id) || 0);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const readAgentItems = (root) => {
|
|
144
|
+
const items = [];
|
|
145
|
+
const dir = path.join(root, LOG_DIR);
|
|
146
|
+
for (const name of agentJsonNames(root)) {
|
|
147
|
+
const item = readJson(path.join(dir, name));
|
|
148
|
+
if (isHashObject(item)) items.push(item);
|
|
149
|
+
}
|
|
150
|
+
items.sort(byStart);
|
|
151
|
+
return items;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const legacyItems = (root) => {
|
|
155
|
+
const planFile = planPath(root, RUNS_FILE);
|
|
156
|
+
const fromPlan = readJson(planFile);
|
|
157
|
+
const prefs = readPrefs(root);
|
|
158
|
+
if (Array.isArray(fromPlan)) return { items: fromPlan, planFile, prefs };
|
|
159
|
+
if (!Array.isArray(prefs.runs)) return null;
|
|
160
|
+
return { items: prefs.runs, planFile: '', prefs };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const dropLegacy = (found, root) => {
|
|
164
|
+
if (found.planFile) fs.rmSync(found.planFile, { force: true });
|
|
165
|
+
if (found.prefs.runs) writePrefs(root, found.prefs);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const readSessions = (root) => {
|
|
169
|
+
const saved = readJson(planPath(root, SESSIONS_FILE));
|
|
170
|
+
return isHashObject(saved) ? saved : {};
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const sessionEntry = (prev, job) => {
|
|
174
|
+
const prior = isHashObject(prev) ? prev : {};
|
|
175
|
+
const entry = { cli: job.cliId || prior.cli || '' };
|
|
176
|
+
const name = stemOf(job.logName) || prior.name || '';
|
|
177
|
+
const startedAt = Number(job.startedAt) || Number(prior.startedAt) || 0;
|
|
178
|
+
const files = typeof job.files === 'number' ? job.files : prior.files;
|
|
179
|
+
const tokens = typeof job.tokens === 'number' ? job.tokens : prior.tokens;
|
|
180
|
+
if (name) entry.name = name;
|
|
181
|
+
if (startedAt) entry.startedAt = startedAt;
|
|
182
|
+
if (typeof files === 'number') entry.files = files;
|
|
183
|
+
if (typeof tokens === 'number') entry.tokens = tokens;
|
|
184
|
+
return entry;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const replacedStems = (jobs) => {
|
|
188
|
+
const stems = new Set();
|
|
189
|
+
for (const job of jobs) {
|
|
190
|
+
const stem = stemOf(job.logName);
|
|
191
|
+
const id = textOf(job.session);
|
|
192
|
+
if (id && stem && id !== stem) stems.add(stem);
|
|
193
|
+
}
|
|
194
|
+
return stems;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const rememberSession = (rows, id, stem, at) => {
|
|
198
|
+
const blank = { id, name: '', startedAt: 0 };
|
|
199
|
+
const prev = rows.get(id) || blank;
|
|
200
|
+
if (at >= prev.startedAt) {
|
|
201
|
+
prev.startedAt = at;
|
|
202
|
+
if (stem) prev.name = stem;
|
|
203
|
+
} else if (!prev.name && stem) {
|
|
204
|
+
prev.name = stem;
|
|
205
|
+
}
|
|
206
|
+
rows.set(id, prev);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const listSessions = (root, jobs, cliId) => {
|
|
210
|
+
const saved = readSessions(root);
|
|
211
|
+
const hidden = replacedStems(jobs);
|
|
212
|
+
const rows = new Map();
|
|
213
|
+
for (const [id, entry] of Object.entries(saved)) {
|
|
214
|
+
if (hidden.has(id)) continue;
|
|
215
|
+
if (!isHashObject(entry) || entry.cli !== cliId) continue;
|
|
216
|
+
const name = textOf(entry.name);
|
|
217
|
+
const startedAt = Number(entry.startedAt) || 0;
|
|
218
|
+
rows.set(id, { id, name, startedAt });
|
|
219
|
+
}
|
|
220
|
+
for (const job of jobs) {
|
|
221
|
+
if (job.cliId !== cliId || job.action === 'login') continue;
|
|
222
|
+
const id = sessionKey(job);
|
|
223
|
+
if (!id) continue;
|
|
224
|
+
const stem = stemOf(job.logName);
|
|
225
|
+
const at = Number(job.startedAt) || 0;
|
|
226
|
+
rememberSession(rows, id, stem, at);
|
|
227
|
+
}
|
|
228
|
+
const list = [...rows.values()];
|
|
229
|
+
list.sort((left, right) => {
|
|
230
|
+
const at = right.startedAt - left.startedAt;
|
|
231
|
+
if (at) return at;
|
|
232
|
+
return right.name.localeCompare(left.name);
|
|
233
|
+
});
|
|
234
|
+
return list;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const panelSessions = (root, jobs, cliId) => {
|
|
238
|
+
const found = cliId ? listSessions(root, jobs, cliId) : [];
|
|
239
|
+
const rows = [{ id: '', name: '<start new session>', fresh: true }];
|
|
240
|
+
for (const session of found) {
|
|
241
|
+
rows.push({
|
|
242
|
+
id: session.id,
|
|
243
|
+
name: session.name || session.id,
|
|
244
|
+
session: true,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return rows;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const runningJobs = (jobs, cliId) => {
|
|
251
|
+
const runs = [];
|
|
252
|
+
for (const job of jobs) {
|
|
253
|
+
if (job.cliId !== cliId || job.status !== 'running') continue;
|
|
254
|
+
if (job.action === 'login') continue;
|
|
255
|
+
runs.push(job);
|
|
256
|
+
}
|
|
257
|
+
return runs;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const liveRun = (job, progress) => {
|
|
261
|
+
const stem = stemOf(job.logName);
|
|
262
|
+
const id = sessionKey(job);
|
|
263
|
+
const command = stem || job.command || job.name;
|
|
264
|
+
return {
|
|
265
|
+
id,
|
|
266
|
+
jobId: job.id,
|
|
267
|
+
name: command,
|
|
268
|
+
status: job.status,
|
|
269
|
+
elapsed: elapsedLabel(job),
|
|
270
|
+
progress,
|
|
271
|
+
command,
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const withLiveRuns = (saved, running, progressOf) => {
|
|
276
|
+
const rows = saved.slice();
|
|
277
|
+
for (const job of running) {
|
|
278
|
+
const id = sessionKey(job);
|
|
279
|
+
if (!id) continue;
|
|
280
|
+
const at = rows.findIndex((row) => row.id === id);
|
|
281
|
+
if (at < 0) continue;
|
|
282
|
+
rows[at] = liveRun(job, progressOf(job));
|
|
283
|
+
}
|
|
284
|
+
const pending = [];
|
|
285
|
+
for (const job of running) {
|
|
286
|
+
const shown = rows.some((row) => row.jobId === job.id);
|
|
287
|
+
if (!shown) pending.push(job);
|
|
288
|
+
}
|
|
289
|
+
pending.sort((left, right) => right.id - left.id);
|
|
290
|
+
if (!pending.length) return rows;
|
|
291
|
+
const added = pending.map((job) => liveRun(job, progressOf(job)));
|
|
292
|
+
return [...rows.slice(0, 1), ...added, ...rows.slice(1)];
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const mergeSessions = (saved, jobs) => {
|
|
296
|
+
const sessions = { ...saved };
|
|
297
|
+
for (const job of jobs) {
|
|
298
|
+
if (job.action === 'login') continue;
|
|
299
|
+
const stem = stemOf(job.logName);
|
|
300
|
+
const id = textOf(job.session);
|
|
301
|
+
const done = job.status !== 'running';
|
|
302
|
+
const key = id || (done ? stem : '');
|
|
303
|
+
if (!key) continue;
|
|
304
|
+
if (id && stem && id !== stem) delete sessions[stem];
|
|
305
|
+
sessions[key] = sessionEntry(sessions[key], job);
|
|
306
|
+
}
|
|
307
|
+
return sessions;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const saveRunFiles = (root, jobs) => {
|
|
311
|
+
const kept = jobs.slice(-RUN_KEEP);
|
|
312
|
+
for (const job of kept) {
|
|
313
|
+
assignLog(root, job);
|
|
314
|
+
writeJob(root, job);
|
|
315
|
+
}
|
|
316
|
+
pruneAgent(root, kept);
|
|
317
|
+
const sessions = mergeSessions(readSessions(root), jobs);
|
|
318
|
+
const file = planPath(root, SESSIONS_FILE);
|
|
319
|
+
const keep = Object.keys(sessions).length > 0 || fs.existsSync(file);
|
|
320
|
+
if (keep) writeStored(file, sessions);
|
|
321
|
+
const prefs = readPrefs(root);
|
|
322
|
+
if (prefs.runs) writePrefs(root, prefs);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const migrateLegacy = (root) => {
|
|
326
|
+
const found = legacyItems(root);
|
|
327
|
+
if (!found) return;
|
|
328
|
+
if (found.items.length) {
|
|
329
|
+
const restored = restoreJobs(found.items);
|
|
330
|
+
saveRunFiles(root, restored.jobs);
|
|
331
|
+
}
|
|
332
|
+
dropLegacy(found, root);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const readRunList = (root) => {
|
|
336
|
+
migrateLegacy(root);
|
|
337
|
+
return readAgentItems(root);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
elapsedLabel,
|
|
342
|
+
launchJob,
|
|
343
|
+
restoreJobs,
|
|
344
|
+
runStats,
|
|
345
|
+
assignLog,
|
|
346
|
+
stemOf,
|
|
347
|
+
orderedSessions,
|
|
348
|
+
runBlock,
|
|
349
|
+
replayLaunch,
|
|
350
|
+
panelSessions,
|
|
351
|
+
runningJobs,
|
|
352
|
+
withLiveRuns,
|
|
353
|
+
saveRunFiles,
|
|
354
|
+
readRunList,
|
|
355
|
+
};
|