atris 3.48.0 → 3.49.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/atris/CLAUDE.md +2 -0
- package/atris/skills/youtube/SKILL.md +2 -2
- package/bin/atris.js +5 -0
- package/commands/dream.js +1 -1
- package/commands/engine.js +12 -5
- package/commands/member.js +38 -1
- package/commands/task.js +94 -22
- package/commands/who.js +176 -0
- package/commands/worktree.js +7 -5
- package/commands/youtube.js +71 -0
- package/lib/engine-registry.js +1 -0
- package/lib/fleet.js +108 -45
- package/lib/known-commands.js +1 -1
- package/lib/task-db.js +2 -2
- package/lib/workforce-presence.js +448 -0
- package/package.json +1 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
|
+
const PROCESS_START_TOLERANCE_MS = 30 * 60 * 1000;
|
|
5
|
+
const ACTIVE_TASK_STATUSES = new Set(['claimed', 'do', 'doing', 'in_progress', 'review']);
|
|
6
|
+
const ACTIVE_MISSION_STATUSES = new Set(['planning', 'active', 'running', 'ready']);
|
|
7
|
+
const RUNNING_RECEIPT_STATUSES = new Set(['active', 'in_progress', 'running', 'started', 'working']);
|
|
8
|
+
const TERMINAL_RECEIPT_STATUSES = new Set([
|
|
9
|
+
'cancelled',
|
|
10
|
+
'completed',
|
|
11
|
+
'done',
|
|
12
|
+
'failed',
|
|
13
|
+
'landed',
|
|
14
|
+
'no_output',
|
|
15
|
+
'passed',
|
|
16
|
+
'presumed_dead',
|
|
17
|
+
'succeeded',
|
|
18
|
+
'timed_out',
|
|
19
|
+
]);
|
|
20
|
+
const WORK_TOKEN_STOP_WORDS = new Set([
|
|
21
|
+
'agent', 'build', 'building', 'engine', 'local', 'mission', 'process', 'running', 'task', 'working',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function timestampMs(value) {
|
|
25
|
+
if (value == null || value === '') return 0;
|
|
26
|
+
if (typeof value === 'number') {
|
|
27
|
+
if (!Number.isFinite(value)) return 0;
|
|
28
|
+
return value > 1000000000000 ? value : value * 1000;
|
|
29
|
+
}
|
|
30
|
+
const numeric = Number(value);
|
|
31
|
+
if (Number.isFinite(numeric) && String(value).trim()) return timestampMs(numeric);
|
|
32
|
+
const parsed = Date.parse(String(value));
|
|
33
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isoTimestamp(value) {
|
|
37
|
+
const ms = timestampMs(value);
|
|
38
|
+
return ms ? new Date(ms).toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ageSeconds(value, nowMs) {
|
|
42
|
+
const ms = timestampMs(value);
|
|
43
|
+
return ms ? Math.max(0, Math.floor((nowMs - ms) / 1000)) : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeEngine(value) {
|
|
47
|
+
const engine = String(value || '').trim().toLowerCase();
|
|
48
|
+
if (!engine) return '';
|
|
49
|
+
if (engine === 'cursor-agent' || engine === 'cursor agent') return 'cursor';
|
|
50
|
+
if (engine === 'claude-code') return 'claude';
|
|
51
|
+
return engine.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function engineForCommand(command) {
|
|
55
|
+
const text = String(command || '');
|
|
56
|
+
if (/ChatGPT\.app|Codex Framework\.framework|Claude\.app/.test(text)) return '';
|
|
57
|
+
const executable = text.trim().split(/\s+/)[0] || '';
|
|
58
|
+
const name = executable.split('/').pop().toLowerCase();
|
|
59
|
+
if (name === 'cursor-agent') return 'cursor';
|
|
60
|
+
if (/^codex(?:-|$)/.test(name)) return 'codex';
|
|
61
|
+
if (name === 'grok') return 'grok';
|
|
62
|
+
if (name === 'devin') return 'devin';
|
|
63
|
+
if (name === 'droid') return 'droid';
|
|
64
|
+
if (name === 'agy') return 'agy';
|
|
65
|
+
if (name === 'claude' && /(^|\s)(?:-p|--print)(?:\s|$)/.test(text)) return 'claude';
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsePsOutput(text) {
|
|
70
|
+
const allRows = [];
|
|
71
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
72
|
+
const parts = line.trim().split(/\s+/);
|
|
73
|
+
if (parts.length < 8) continue;
|
|
74
|
+
const pid = Number(parts[0]);
|
|
75
|
+
const ppid = Number(parts[1]);
|
|
76
|
+
const command = parts.slice(7).join(' ');
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
78
|
+
const started = Date.parse(parts.slice(2, 7).join(' '));
|
|
79
|
+
allRows.push({
|
|
80
|
+
pid,
|
|
81
|
+
ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : null,
|
|
82
|
+
command,
|
|
83
|
+
started_at: Number.isFinite(started) ? new Date(started).toISOString() : null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const byPid = new Map(allRows.map((row) => [row.pid, row]));
|
|
87
|
+
const engineRows = allRows
|
|
88
|
+
.map((row) => ({ ...row, engine: engineForCommand(row.command) }))
|
|
89
|
+
.filter((row) => row.engine);
|
|
90
|
+
const parentPids = new Set(engineRows.map((row) => row.ppid).filter(Boolean));
|
|
91
|
+
return engineRows
|
|
92
|
+
.filter((row) => !parentPids.has(row.pid))
|
|
93
|
+
.map((row) => {
|
|
94
|
+
const ancestorPids = [];
|
|
95
|
+
let parent = row.ppid;
|
|
96
|
+
while (parent && !ancestorPids.includes(parent) && ancestorPids.length < 64) {
|
|
97
|
+
ancestorPids.push(parent);
|
|
98
|
+
parent = byPid.get(parent)?.ppid || null;
|
|
99
|
+
}
|
|
100
|
+
return { ...row, ancestor_pids: ancestorPids };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeProcesses(processes) {
|
|
105
|
+
const byPid = new Map();
|
|
106
|
+
for (const row of Array.isArray(processes) ? processes : []) {
|
|
107
|
+
const pid = Number(row?.pid);
|
|
108
|
+
const engine = normalizeEngine(row?.engine) || engineForCommand(row?.command);
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0 || !engine) continue;
|
|
110
|
+
byPid.set(pid, {
|
|
111
|
+
pid,
|
|
112
|
+
ppid: Number(row?.ppid) || null,
|
|
113
|
+
engine,
|
|
114
|
+
command: String(row?.command || ''),
|
|
115
|
+
started_at: isoTimestamp(row?.started_at || row?.start || row?.at),
|
|
116
|
+
ancestor_pids: (Array.isArray(row?.ancestor_pids) ? row.ancestor_pids : [])
|
|
117
|
+
.map(Number)
|
|
118
|
+
.filter((value) => Number.isInteger(value) && value > 0),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return [...byPid.values()].sort((left, right) => left.pid - right.pid);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function taskRef(task) {
|
|
125
|
+
return String(task?.display_id || task?.legacy_ref || task?.id || '').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function taskOwner(task) {
|
|
129
|
+
return String(task?.claimed_by || task?.assigned_to || task?.metadata?.assigned_to || '').trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function taskActivity(task) {
|
|
133
|
+
return task?.updated_at || task?.claimed_at || task?.created_at || null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function receiptTaskRefs(receipt) {
|
|
137
|
+
const values = [receipt?.task_id, receipt?.task];
|
|
138
|
+
if (Array.isArray(receipt?.tasks)) values.push(...receipt.tasks);
|
|
139
|
+
if (Array.isArray(receipt?.task_ids)) values.push(...receipt.task_ids);
|
|
140
|
+
if (Array.isArray(receipt?.results)) values.push(...receipt.results.map((row) => row?.task || row?.task_id));
|
|
141
|
+
return [...new Set(values.map((value) => {
|
|
142
|
+
if (value && typeof value === 'object') return value.display_id || value.id || value.task;
|
|
143
|
+
return value;
|
|
144
|
+
}).map((value) => String(value || '').trim()).filter(Boolean))];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function receiptEngine(receipt) {
|
|
148
|
+
return normalizeEngine(
|
|
149
|
+
receipt?.engine
|
|
150
|
+
|| receipt?.engines?.[0]
|
|
151
|
+
|| receipt?.results?.find((row) => row?.engine)?.engine,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function receiptStatus(receipt) {
|
|
156
|
+
return String(receipt?.status || '').trim().toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRunningReceipt(receipt) {
|
|
160
|
+
return !receipt?.finished_at && RUNNING_RECEIPT_STATUSES.has(receiptStatus(receipt));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isFinishedReceipt(receipt) {
|
|
164
|
+
if (!receipt || isRunningReceipt(receipt)) return false;
|
|
165
|
+
return Boolean(receipt.finished_at || TERMINAL_RECEIPT_STATUSES.has(receiptStatus(receipt)));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function receiptStartedAt(receipt, fallback) {
|
|
169
|
+
return receipt?.started_at || receipt?.at || receipt?.created_at || fallback || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function receiptFinishedAt(receipt, fallback) {
|
|
173
|
+
return receipt?.finished_at || receipt?.completed_at || receipt?.updated_at || fallback || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function finalResult(receipt) {
|
|
177
|
+
if (typeof receipt?.result === 'string') return receipt.result.trim();
|
|
178
|
+
if (receipt?.result && typeof receipt.result === 'object') {
|
|
179
|
+
const kind = String(receipt.result.kind || '').trim();
|
|
180
|
+
if (typeof receipt.result.passed === 'boolean') return `${kind || 'result'} ${receipt.result.passed ? 'passed' : 'failed'}`;
|
|
181
|
+
if (kind) return kind;
|
|
182
|
+
}
|
|
183
|
+
if (receipt?.summary && typeof receipt.summary === 'object') {
|
|
184
|
+
const answered = Number(receipt.summary.answered) || 0;
|
|
185
|
+
const failed = Number(receipt.summary.failed) || 0;
|
|
186
|
+
if (answered || failed) return `${answered} answered, ${failed} failed`;
|
|
187
|
+
}
|
|
188
|
+
return receiptStatus(receipt) || 'finished';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function taskLookup(tasks) {
|
|
192
|
+
const byRef = new Map();
|
|
193
|
+
for (const task of tasks) {
|
|
194
|
+
for (const ref of [task?.id, task?.display_id, task?.legacy_ref]) {
|
|
195
|
+
const key = String(ref || '').trim().toLowerCase();
|
|
196
|
+
if (key) byRef.set(key, task);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return byRef;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function firstTaskForRefs(refs, byRef) {
|
|
203
|
+
for (const ref of refs) {
|
|
204
|
+
const task = byRef.get(String(ref).toLowerCase());
|
|
205
|
+
if (task) return task;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rowTask(refs, task) {
|
|
211
|
+
return taskRef(task) || refs[0] || null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function baseRow({ member, task, title, engine, source, at, nowMs }) {
|
|
215
|
+
return {
|
|
216
|
+
member: member || null,
|
|
217
|
+
task: task || null,
|
|
218
|
+
title: title || null,
|
|
219
|
+
engine: engine || null,
|
|
220
|
+
source,
|
|
221
|
+
at: isoTimestamp(at),
|
|
222
|
+
age_seconds: ageSeconds(at, nowMs),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildWorkforcePresence(input = {}) {
|
|
227
|
+
const nowMs = timestampMs(input.nowMs ?? input.now ?? Date.now()) || Date.now();
|
|
228
|
+
const staleAfterMs = Number(input.staleAfterMs) > 0 ? Number(input.staleAfterMs) : DEFAULT_STALE_AFTER_MS;
|
|
229
|
+
const tasks = (Array.isArray(input.tasks) ? input.tasks : [])
|
|
230
|
+
.filter((task) => ACTIVE_TASK_STATUSES.has(String(task?.status || '').toLowerCase()) && taskOwner(task));
|
|
231
|
+
const missions = (Array.isArray(input.missions) ? input.missions : [])
|
|
232
|
+
.filter((mission) => ACTIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase()));
|
|
233
|
+
const receipts = Array.isArray(input.receipts) ? input.receipts : [];
|
|
234
|
+
const processes = normalizeProcesses(input.processes);
|
|
235
|
+
const byTaskRef = taskLookup(tasks);
|
|
236
|
+
const usedPids = new Set();
|
|
237
|
+
const representedTasks = new Set();
|
|
238
|
+
const working = [];
|
|
239
|
+
const waiting = [];
|
|
240
|
+
const done = [];
|
|
241
|
+
const stale = [];
|
|
242
|
+
|
|
243
|
+
const claimProcess = (engine, pid, expectedStart) => {
|
|
244
|
+
const wantedPid = Number(pid);
|
|
245
|
+
if (Number.isInteger(wantedPid) && wantedPid > 0) {
|
|
246
|
+
const exact = processes.find((row) => (
|
|
247
|
+
row.pid === wantedPid || row.ancestor_pids.includes(wantedPid)
|
|
248
|
+
) && row.engine === engine && !usedPids.has(row.pid) && (
|
|
249
|
+
!timestampMs(expectedStart)
|
|
250
|
+
|| !timestampMs(row.started_at)
|
|
251
|
+
|| Math.abs(timestampMs(row.started_at) - timestampMs(expectedStart)) <= PROCESS_START_TOLERANCE_MS
|
|
252
|
+
));
|
|
253
|
+
if (exact) usedPids.add(exact.pid);
|
|
254
|
+
return exact || null;
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
};
|
|
258
|
+
const workTokens = (value) => new Set(
|
|
259
|
+
(String(value || '').toLowerCase().match(/[a-z][a-z0-9]{4,}/g) || [])
|
|
260
|
+
.filter((token) => !WORK_TOKEN_STOP_WORDS.has(token)),
|
|
261
|
+
);
|
|
262
|
+
const processMatchesWork = (row, refs, title) => {
|
|
263
|
+
const command = String(row.command || '').toLowerCase();
|
|
264
|
+
if (refs.some((ref) => command.includes(String(ref).toLowerCase()))) return true;
|
|
265
|
+
const titleTokens = workTokens(title);
|
|
266
|
+
const commandTokens = workTokens(command);
|
|
267
|
+
return [...titleTokens].some((token) => commandTokens.has(token));
|
|
268
|
+
};
|
|
269
|
+
const claimMatchingProcess = (engine, refs, title) => {
|
|
270
|
+
const candidate = processes.find((row) => (
|
|
271
|
+
(!engine || row.engine === engine)
|
|
272
|
+
&& !usedPids.has(row.pid)
|
|
273
|
+
&& processMatchesWork(row, refs, title)
|
|
274
|
+
));
|
|
275
|
+
if (candidate) usedPids.add(candidate.pid);
|
|
276
|
+
return candidate || null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
for (const entry of receipts) {
|
|
280
|
+
const receipt = entry?.receipt || entry;
|
|
281
|
+
const engine = receiptEngine(receipt);
|
|
282
|
+
if (!engine) continue;
|
|
283
|
+
const refs = receiptTaskRefs(receipt);
|
|
284
|
+
const task = firstTaskForRefs(refs, byTaskRef);
|
|
285
|
+
const member = String(receipt?.member || receipt?.owner || receipt?.actor || taskOwner(task) || '').trim();
|
|
286
|
+
const taskValue = rowTask(refs, task);
|
|
287
|
+
const startedAt = receiptStartedAt(receipt, entry?.mtimeMs);
|
|
288
|
+
const common = baseRow({
|
|
289
|
+
member,
|
|
290
|
+
task: taskValue,
|
|
291
|
+
title: task?.title || receipt?.objective || '',
|
|
292
|
+
engine,
|
|
293
|
+
source: 'receipt',
|
|
294
|
+
at: startedAt,
|
|
295
|
+
nowMs,
|
|
296
|
+
});
|
|
297
|
+
if (isRunningReceipt(receipt)) {
|
|
298
|
+
const processRow = claimProcess(engine, receipt.pid, startedAt)
|
|
299
|
+
|| claimMatchingProcess(engine, refs, task?.title || receipt?.objective || '');
|
|
300
|
+
const row = {
|
|
301
|
+
...common,
|
|
302
|
+
pid: processRow?.pid || Number(receipt.pid) || null,
|
|
303
|
+
receipt: entry?.name || receipt?.receipt || null,
|
|
304
|
+
};
|
|
305
|
+
if (processRow) working.push(row);
|
|
306
|
+
else if (Number(receipt.pid) > 0 || nowMs - timestampMs(startedAt) > staleAfterMs) {
|
|
307
|
+
stale.push({ ...row, reason: 'run has no live process' });
|
|
308
|
+
} else {
|
|
309
|
+
waiting.push({ ...row, reason: 'run has not started a local process' });
|
|
310
|
+
}
|
|
311
|
+
if (taskValue) representedTasks.add(String(taskValue).toLowerCase());
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (isFinishedReceipt(receipt)) {
|
|
315
|
+
done.push({
|
|
316
|
+
...common,
|
|
317
|
+
at: isoTimestamp(receiptFinishedAt(receipt, entry?.mtimeMs)),
|
|
318
|
+
age_seconds: ageSeconds(receiptFinishedAt(receipt, entry?.mtimeMs), nowMs),
|
|
319
|
+
run_status: receiptStatus(receipt) || 'finished',
|
|
320
|
+
result: finalResult(receipt),
|
|
321
|
+
receipt: entry?.name || receipt?.receipt || null,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const mission of missions) {
|
|
327
|
+
const refs = Array.isArray(mission?.task_ids) ? mission.task_ids.map(String) : [];
|
|
328
|
+
if (refs.some((ref) => representedTasks.has(ref.toLowerCase()))) continue;
|
|
329
|
+
const task = firstTaskForRefs(refs, byTaskRef);
|
|
330
|
+
const engine = normalizeEngine(mission?.runner || mission?.engine || mission?.executed_by);
|
|
331
|
+
const member = String(mission?.owner || mission?.member || taskOwner(task) || '').trim();
|
|
332
|
+
const at = mission?.last_tick_at || mission?.updated_at || mission?.created_at;
|
|
333
|
+
const common = baseRow({
|
|
334
|
+
member,
|
|
335
|
+
task: rowTask(refs, task),
|
|
336
|
+
title: task?.title || mission?.objective || mission?.name || '',
|
|
337
|
+
engine,
|
|
338
|
+
source: 'mission',
|
|
339
|
+
at,
|
|
340
|
+
nowMs,
|
|
341
|
+
});
|
|
342
|
+
const processRow = engine
|
|
343
|
+
? claimProcess(engine, mission?.pid, at) || claimMatchingProcess(engine, refs, common.title)
|
|
344
|
+
: null;
|
|
345
|
+
if (processRow) working.push({ ...common, pid: processRow.pid, mission: mission?.id || null });
|
|
346
|
+
else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'mission has no live process', mission: mission?.id || null });
|
|
347
|
+
else waiting.push({ ...common, reason: 'mission is waiting for a local process', mission: mission?.id || null });
|
|
348
|
+
for (const ref of refs) representedTasks.add(ref.toLowerCase());
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const task of tasks) {
|
|
352
|
+
const ref = taskRef(task);
|
|
353
|
+
if (representedTasks.has(ref.toLowerCase())) continue;
|
|
354
|
+
const engine = normalizeEngine(task?.executed_by || task?.metadata?.executed_by || task?.metadata?.engine);
|
|
355
|
+
const at = taskActivity(task);
|
|
356
|
+
const common = baseRow({
|
|
357
|
+
member: taskOwner(task),
|
|
358
|
+
task: ref,
|
|
359
|
+
title: task?.title || '',
|
|
360
|
+
engine,
|
|
361
|
+
source: 'task',
|
|
362
|
+
at,
|
|
363
|
+
nowMs,
|
|
364
|
+
});
|
|
365
|
+
const processRow = claimProcess(engine, task?.pid || task?.metadata?.pid, at)
|
|
366
|
+
|| claimMatchingProcess(engine, [ref], '');
|
|
367
|
+
if (processRow) working.push({ ...common, engine: processRow.engine, pid: processRow.pid });
|
|
368
|
+
else if (nowMs - timestampMs(at) > staleAfterMs) stale.push({ ...common, reason: 'claim is older than seven days with no live process' });
|
|
369
|
+
else waiting.push({ ...common, reason: 'claim has no live process yet' });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const unowned = processes
|
|
373
|
+
.filter((row) => !usedPids.has(row.pid))
|
|
374
|
+
.map((row) => ({
|
|
375
|
+
engine: row.engine,
|
|
376
|
+
pid: row.pid,
|
|
377
|
+
command: row.command,
|
|
378
|
+
started_at: row.started_at,
|
|
379
|
+
age_seconds: ageSeconds(row.started_at, nowMs),
|
|
380
|
+
reason: 'no matching claim, mission, or run receipt',
|
|
381
|
+
}));
|
|
382
|
+
|
|
383
|
+
const newestFirst = (left, right) => timestampMs(right.at || right.started_at) - timestampMs(left.at || left.started_at);
|
|
384
|
+
working.sort(newestFirst);
|
|
385
|
+
waiting.sort(newestFirst);
|
|
386
|
+
done.sort(newestFirst);
|
|
387
|
+
stale.sort(newestFirst);
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
schema: 'atris.workforce_presence.v1',
|
|
391
|
+
generated_at: new Date(nowMs).toISOString(),
|
|
392
|
+
stale_after_seconds: Math.round(staleAfterMs / 1000),
|
|
393
|
+
totals: {
|
|
394
|
+
working: working.length,
|
|
395
|
+
waiting: waiting.length,
|
|
396
|
+
done: done.length,
|
|
397
|
+
stale: stale.length,
|
|
398
|
+
unowned: unowned.length,
|
|
399
|
+
},
|
|
400
|
+
working,
|
|
401
|
+
waiting,
|
|
402
|
+
done,
|
|
403
|
+
stale,
|
|
404
|
+
unowned,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function formatAge(seconds) {
|
|
409
|
+
if (seconds == null) return 'age unknown';
|
|
410
|
+
if (seconds < 60) return `${seconds}s`;
|
|
411
|
+
const minutes = Math.floor(seconds / 60);
|
|
412
|
+
if (minutes < 60) return `${minutes}m`;
|
|
413
|
+
const hours = Math.floor(minutes / 60);
|
|
414
|
+
if (hours < 48) return `${hours}h`;
|
|
415
|
+
return `${Math.floor(hours / 24)}d`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function rowSubject(row) {
|
|
419
|
+
const member = row.member || 'unassigned';
|
|
420
|
+
const task = row.task || row.title || 'local work';
|
|
421
|
+
return `${member}: ${row.engine || 'unknown engine'} on ${task}`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderWorkforcePresence(presence) {
|
|
425
|
+
const lines = [];
|
|
426
|
+
const section = (name, rows, render, limit = rows.length) => {
|
|
427
|
+
lines.push(`${name}:`);
|
|
428
|
+
if (!rows.length) lines.push(' none');
|
|
429
|
+
else rows.slice(0, limit).forEach((row) => lines.push(` ${render(row)}`));
|
|
430
|
+
if (rows.length > limit) lines.push(` ${rows.length - limit} more; clear finished runs with atris who --clear`);
|
|
431
|
+
};
|
|
432
|
+
section('working', presence.working, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}, pid ${row.pid || '?'})`);
|
|
433
|
+
section('waiting', presence.waiting, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
434
|
+
section('done', presence.done, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.run_status}: ${row.result}`, 10);
|
|
435
|
+
section('stale', presence.stale, (row) => `${rowSubject(row)} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
436
|
+
section('unowned', presence.unowned, (row) => `${row.engine} pid ${row.pid} (${formatAge(row.age_seconds)}), ${row.reason}`);
|
|
437
|
+
const totals = presence.totals;
|
|
438
|
+
lines.push(`totals: ${totals.working} working, ${totals.waiting} waiting, ${totals.done} done, ${totals.stale} stale, ${totals.unowned} unowned`);
|
|
439
|
+
return lines.join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
module.exports = {
|
|
443
|
+
buildWorkforcePresence,
|
|
444
|
+
isFinishedReceipt,
|
|
445
|
+
parsePsOutput,
|
|
446
|
+
receiptEngine,
|
|
447
|
+
renderWorkforcePresence,
|
|
448
|
+
};
|