@monoes/monomindcli 2.13.0 → 2.14.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/dist/src/commands/doctor-mcp-probe.d.ts +48 -0
- package/dist/src/commands/doctor-mcp-probe.d.ts.map +1 -0
- package/dist/src/commands/doctor-mcp-probe.js +205 -0
- package/dist/src/commands/doctor-mcp-probe.js.map +1 -0
- package/dist/src/commands/doctor-project-checks.d.ts +12 -1
- package/dist/src/commands/doctor-project-checks.d.ts.map +1 -1
- package/dist/src/commands/doctor-project-checks.js +55 -4
- package/dist/src/commands/doctor-project-checks.js.map +1 -1
- package/dist/src/commands/doctor.d.ts.map +1 -1
- package/dist/src/commands/doctor.js +10 -1
- package/dist/src/commands/doctor.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +17 -0
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/mcp.d.ts.map +1 -1
- package/dist/src/commands/mcp.js +4 -3
- package/dist/src/commands/mcp.js.map +1 -1
- package/dist/src/commands/org-observe.d.ts.map +1 -1
- package/dist/src/commands/org-observe.js +14 -0
- package/dist/src/commands/org-observe.js.map +1 -1
- package/dist/src/commands/org.d.ts.map +1 -1
- package/dist/src/commands/org.js +12 -2
- package/dist/src/commands/org.js.map +1 -1
- package/dist/src/init/claudemd-generator.d.ts.map +1 -1
- package/dist/src/init/claudemd-generator.js +2 -1
- package/dist/src/init/claudemd-generator.js.map +1 -1
- package/dist/src/init/mcp-generator.d.ts.map +1 -1
- package/dist/src/init/mcp-generator.js +3 -3
- package/dist/src/init/mcp-generator.js.map +1 -1
- package/dist/src/init/types.d.ts +6 -0
- package/dist/src/init/types.d.ts.map +1 -1
- package/dist/src/init/types.js.map +1 -1
- package/dist/src/init/write-runtime-config.d.ts.map +1 -1
- package/dist/src/init/write-runtime-config.js +12 -2
- package/dist/src/init/write-runtime-config.js.map +1 -1
- package/dist/src/orgrt/agent-runner.d.ts +38 -0
- package/dist/src/orgrt/agent-runner.d.ts.map +1 -1
- package/dist/src/orgrt/agent-runner.js +47 -0
- package/dist/src/orgrt/agent-runner.js.map +1 -1
- package/dist/src/orgrt/checkpoint.d.ts +12 -1
- package/dist/src/orgrt/checkpoint.d.ts.map +1 -1
- package/dist/src/orgrt/checkpoint.js +15 -6
- package/dist/src/orgrt/checkpoint.js.map +1 -1
- package/dist/src/orgrt/completion-gate.d.ts +74 -0
- package/dist/src/orgrt/completion-gate.d.ts.map +1 -1
- package/dist/src/orgrt/completion-gate.js +45 -0
- package/dist/src/orgrt/completion-gate.js.map +1 -1
- package/dist/src/orgrt/cost-tier.d.ts +144 -0
- package/dist/src/orgrt/cost-tier.d.ts.map +1 -0
- package/dist/src/orgrt/cost-tier.js +183 -0
- package/dist/src/orgrt/cost-tier.js.map +1 -0
- package/dist/src/orgrt/cross-org.d.ts.map +1 -1
- package/dist/src/orgrt/cross-org.js +14 -0
- package/dist/src/orgrt/cross-org.js.map +1 -1
- package/dist/src/orgrt/daemon.d.ts +12 -1
- package/dist/src/orgrt/daemon.d.ts.map +1 -1
- package/dist/src/orgrt/daemon.js +216 -49
- package/dist/src/orgrt/daemon.js.map +1 -1
- package/dist/src/orgrt/decisions.d.ts +14 -2
- package/dist/src/orgrt/decisions.d.ts.map +1 -1
- package/dist/src/orgrt/decisions.js +216 -10
- package/dist/src/orgrt/decisions.js.map +1 -1
- package/dist/src/orgrt/file-roots.d.ts +7 -0
- package/dist/src/orgrt/file-roots.d.ts.map +1 -1
- package/dist/src/orgrt/file-roots.js +38 -1
- package/dist/src/orgrt/file-roots.js.map +1 -1
- package/dist/src/orgrt/forwarder.d.ts.map +1 -1
- package/dist/src/orgrt/forwarder.js +16 -2
- package/dist/src/orgrt/forwarder.js.map +1 -1
- package/dist/src/orgrt/idle-deadline.d.ts +74 -1
- package/dist/src/orgrt/idle-deadline.d.ts.map +1 -1
- package/dist/src/orgrt/idle-deadline.js +52 -2
- package/dist/src/orgrt/idle-deadline.js.map +1 -1
- package/dist/src/orgrt/loadouts.d.ts +38 -0
- package/dist/src/orgrt/loadouts.d.ts.map +1 -0
- package/dist/src/orgrt/loadouts.js +129 -0
- package/dist/src/orgrt/loadouts.js.map +1 -0
- package/dist/src/orgrt/mailbox.d.ts +17 -1
- package/dist/src/orgrt/mailbox.d.ts.map +1 -1
- package/dist/src/orgrt/mailbox.js +50 -3
- package/dist/src/orgrt/mailbox.js.map +1 -1
- package/dist/src/orgrt/policy.d.ts +42 -2
- package/dist/src/orgrt/policy.d.ts.map +1 -1
- package/dist/src/orgrt/policy.js +53 -8
- package/dist/src/orgrt/policy.js.map +1 -1
- package/dist/src/orgrt/prompt-vars.d.ts +11 -0
- package/dist/src/orgrt/prompt-vars.d.ts.map +1 -0
- package/dist/src/orgrt/prompt-vars.js +49 -0
- package/dist/src/orgrt/prompt-vars.js.map +1 -0
- package/dist/src/orgrt/questions.d.ts +22 -1
- package/dist/src/orgrt/questions.d.ts.map +1 -1
- package/dist/src/orgrt/questions.js +24 -3
- package/dist/src/orgrt/questions.js.map +1 -1
- package/dist/src/orgrt/review-packet.d.ts +29 -0
- package/dist/src/orgrt/review-packet.d.ts.map +1 -0
- package/dist/src/orgrt/review-packet.js +67 -0
- package/dist/src/orgrt/review-packet.js.map +1 -0
- package/dist/src/orgrt/role-sandbox.d.ts.map +1 -1
- package/dist/src/orgrt/role-sandbox.js +16 -3
- package/dist/src/orgrt/role-sandbox.js.map +1 -1
- package/dist/src/orgrt/session-ledger.d.ts +61 -0
- package/dist/src/orgrt/session-ledger.d.ts.map +1 -0
- package/dist/src/orgrt/session-ledger.js +113 -0
- package/dist/src/orgrt/session-ledger.js.map +1 -0
- package/dist/src/orgrt/session.d.ts +45 -5
- package/dist/src/orgrt/session.d.ts.map +1 -1
- package/dist/src/orgrt/session.js +384 -27
- package/dist/src/orgrt/session.js.map +1 -1
- package/dist/src/orgrt/task-dag.d.ts +34 -1
- package/dist/src/orgrt/task-dag.d.ts.map +1 -1
- package/dist/src/orgrt/task-dag.js +58 -7
- package/dist/src/orgrt/task-dag.js.map +1 -1
- package/dist/src/orgrt/tool-spill.d.ts +94 -0
- package/dist/src/orgrt/tool-spill.d.ts.map +1 -0
- package/dist/src/orgrt/tool-spill.js +180 -0
- package/dist/src/orgrt/tool-spill.js.map +1 -0
- package/dist/src/orgrt/types.d.ts +138 -0
- package/dist/src/orgrt/types.d.ts.map +1 -1
- package/dist/src/orgrt/types.js +148 -0
- package/dist/src/orgrt/types.js.map +1 -1
- package/dist/src/platform-adapters/renderers/mcp.d.ts +20 -2
- package/dist/src/platform-adapters/renderers/mcp.d.ts.map +1 -1
- package/dist/src/platform-adapters/renderers/mcp.js +25 -5
- package/dist/src/platform-adapters/renderers/mcp.js.map +1 -1
- package/dist/src/protocol-capabilities.d.ts +2 -1
- package/dist/src/protocol-capabilities.d.ts.map +1 -1
- package/dist/src/protocol-capabilities.js +2 -1
- package/dist/src/protocol-capabilities.js.map +1 -1
- package/dist/src/ui/dashboard.html +450 -164
- package/dist/src/ui/org-hil.mjs +276 -0
- package/dist/src/ui/org-runtime.mjs +350 -0
- package/dist/src/ui/orgs.html +15 -4
- package/dist/src/ui/routes-org.mjs +290 -541
- package/dist/src/ui/server.mjs +25 -5
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Human-in-the-loop actions for the dashboard: approvals, decision gates,
|
|
2
|
+
// ask_human answers and human chat messages for Org Runtime v2 orgs.
|
|
3
|
+
//
|
|
4
|
+
// Same shape as the `monomind org approve|deny|gate-*|answer` commands
|
|
5
|
+
// (commands/org-observe.ts): when a daemon hosts the org, the decision goes to
|
|
6
|
+
// its operator-only routes with the operator credential, and the daemon both
|
|
7
|
+
// records it and wakes the waiting role. When no daemon hosts the org, the
|
|
8
|
+
// decision is recorded in the org's own files, where the next run finds it.
|
|
9
|
+
// Unlike the CLI, a live daemon that REJECTS the call is reported as an error
|
|
10
|
+
// instead of falling back to a file write the daemon would overwrite.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
// Compiled from orgrt/*.ts, like forwarder.js in routes-org.mjs.
|
|
14
|
+
import { lookupOrg, readOperatorCredential } from '../orgrt/broker.js';
|
|
15
|
+
import { queueMessage } from '../orgrt/inbox.js';
|
|
16
|
+
|
|
17
|
+
/** Recorded as resolvedBy on everything decided here (normalizeResolver-valid). */
|
|
18
|
+
export const DASHBOARD_RESOLVER = 'human:dashboard';
|
|
19
|
+
|
|
20
|
+
const orgDir = (root, org) => path.join(root, '.monomind', 'orgs', org);
|
|
21
|
+
|
|
22
|
+
class HilError extends Error {
|
|
23
|
+
constructor(status, message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read `<orgDir>/<file>` → its `key` array. Missing file → []. A file that
|
|
30
|
+
* exists but can't be parsed THROWS: callers rewrite what this returns, and
|
|
31
|
+
* rewriting from [] would erase every other entry. */
|
|
32
|
+
function readList(root, org, file, key) {
|
|
33
|
+
const p = path.join(orgDir(root, org), file);
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(p, 'utf8');
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err.code === 'ENOENT') return [];
|
|
39
|
+
throw new HilError(500, `cannot read ${p}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(raw);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new HilError(500, `${p} is not valid JSON (${err.message})`);
|
|
46
|
+
}
|
|
47
|
+
const list = parsed?.[key] ?? [];
|
|
48
|
+
if (!Array.isArray(list)) throw new HilError(500, `${p}: "${key}" is not an array`);
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeList(root, org, file, key, list) {
|
|
53
|
+
const dir = orgDir(root, org);
|
|
54
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
55
|
+
const dest = path.join(dir, file);
|
|
56
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify({ [key]: list }, null, 2));
|
|
58
|
+
fs.renameSync(tmp, dest);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** POST to the hosting daemon's operator-only `route`. Returns null when no
|
|
62
|
+
* live daemon hosts `org`; otherwise the daemon's verdict. */
|
|
63
|
+
async function callDaemon(org, route, body) {
|
|
64
|
+
const remote = lookupOrg(org);
|
|
65
|
+
if (!remote) return null;
|
|
66
|
+
const cred = readOperatorCredential(org);
|
|
67
|
+
if (!cred)
|
|
68
|
+
throw new HilError(
|
|
69
|
+
503,
|
|
70
|
+
`org "${org}" is running, but its operator credential (~/.monomind/orgrt-operator/${org}.json) is missing — the dashboard cannot act for a human`,
|
|
71
|
+
);
|
|
72
|
+
let res;
|
|
73
|
+
try {
|
|
74
|
+
res = await fetch(`${remote.url}${route}`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json', 'x-monomind-cred': cred },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new HilError(502, `org "${org}" daemon unreachable: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
const data = await res.json().catch(() => ({}));
|
|
84
|
+
if (!res.ok || !data.ok)
|
|
85
|
+
throw new HilError(
|
|
86
|
+
res.status >= 400 ? res.status : 502,
|
|
87
|
+
data.error || `daemon returned ${res.status}`,
|
|
88
|
+
);
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function approvalStatus(a) {
|
|
93
|
+
if (a.approved === true) return 'approved';
|
|
94
|
+
if (a.approved === false) return 'denied';
|
|
95
|
+
return 'pending';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The org's tool/action approvals (`<org>/approvals.json`), newest first. */
|
|
99
|
+
export function listApprovals(root, org) {
|
|
100
|
+
return readList(root, org, 'approvals.json', 'approvals')
|
|
101
|
+
.map((a) => ({
|
|
102
|
+
id: a.requestId || null,
|
|
103
|
+
roleId: a.roleId,
|
|
104
|
+
action: a.action,
|
|
105
|
+
input: a.input || null,
|
|
106
|
+
status: approvalStatus(a),
|
|
107
|
+
ts: a.ts || null,
|
|
108
|
+
resolvedBy: a.resolvedBy || null,
|
|
109
|
+
resolvedAt: a.resolvedAt || null,
|
|
110
|
+
}))
|
|
111
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The org's decision gates (`<org>/gates.json`), newest first. */
|
|
115
|
+
export function listGates(root, org) {
|
|
116
|
+
return readList(root, org, 'gates.json', 'gates')
|
|
117
|
+
.map((g) => ({
|
|
118
|
+
id: g.id,
|
|
119
|
+
name: g.name,
|
|
120
|
+
description: g.description,
|
|
121
|
+
roleId: g.roleId,
|
|
122
|
+
status: g.status || 'pending',
|
|
123
|
+
createdAt: g.createdAt || null,
|
|
124
|
+
resolvedBy: g.resolvedBy || null,
|
|
125
|
+
resolvedAt: g.resolvedAt || null,
|
|
126
|
+
resolution: g.resolution || null,
|
|
127
|
+
}))
|
|
128
|
+
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The org's ask_human questions. `blocking` is false only when the role said
|
|
132
|
+
* it can carry on without an answer; pre-D4 records read as blocking. */
|
|
133
|
+
export function listQuestions(root, org) {
|
|
134
|
+
return readList(root, org, 'questions.json', 'questions').map((q) => ({
|
|
135
|
+
...q,
|
|
136
|
+
blocking: q.blocking !== false,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Everything awaiting a human across every org in `root`. */
|
|
141
|
+
export function pendingForProject(root) {
|
|
142
|
+
const base = path.join(root, '.monomind', 'orgs');
|
|
143
|
+
const out = { questions: [], approvals: [], gates: [], errors: [] };
|
|
144
|
+
let entries = [];
|
|
145
|
+
try {
|
|
146
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
147
|
+
} catch {
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
if (!e.isDirectory() || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(e.name)) continue;
|
|
152
|
+
const org = e.name;
|
|
153
|
+
try {
|
|
154
|
+
for (const q of listQuestions(root, org)) out.questions.push({ org, ...q });
|
|
155
|
+
for (const a of listApprovals(root, org)) out.approvals.push({ org, ...a });
|
|
156
|
+
for (const g of listGates(root, org)) out.gates.push({ org, ...g });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
out.errors.push({ org, error: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function resolveApproval(root, org, requestId, approved) {
|
|
165
|
+
const entry = readList(root, org, 'approvals.json', 'approvals').find(
|
|
166
|
+
(a) => a.requestId === requestId,
|
|
167
|
+
);
|
|
168
|
+
if (!entry) throw new HilError(404, `approval "${requestId}" not found for org "${org}"`);
|
|
169
|
+
if (entry.approved !== null)
|
|
170
|
+
throw new HilError(409, `approval "${requestId}" already ${approvalStatus(entry)}`);
|
|
171
|
+
const live = await callDaemon(org, '/api/set-approval', {
|
|
172
|
+
org,
|
|
173
|
+
role: entry.roleId,
|
|
174
|
+
action: entry.action,
|
|
175
|
+
approved,
|
|
176
|
+
resolvedBy: DASHBOARD_RESOLVER,
|
|
177
|
+
requestId,
|
|
178
|
+
});
|
|
179
|
+
if (live) return { delivery: 'live' };
|
|
180
|
+
// Offline: re-read so a write since the lookup above isn't reverted.
|
|
181
|
+
const fresh = readList(root, org, 'approvals.json', 'approvals');
|
|
182
|
+
const item = fresh.find((a) => a.requestId === requestId);
|
|
183
|
+
if (!item || item.approved !== null)
|
|
184
|
+
throw new HilError(409, `approval "${requestId}" was resolved meanwhile`);
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
Object.assign(item, { approved, ts: now, resolvedBy: DASHBOARD_RESOLVER, resolvedAt: now });
|
|
187
|
+
writeList(root, org, 'approvals.json', 'approvals', fresh);
|
|
188
|
+
return { delivery: 'recorded' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function resolveGate(root, org, gateId, approved, resolution) {
|
|
192
|
+
const gate = readList(root, org, 'gates.json', 'gates').find((g) => g.id === gateId);
|
|
193
|
+
if (!gate) throw new HilError(404, `gate "${gateId}" not found for org "${org}"`);
|
|
194
|
+
if (gate.status !== 'pending')
|
|
195
|
+
throw new HilError(409, `gate "${gateId}" already resolved (${gate.status})`);
|
|
196
|
+
const live = await callDaemon(org, '/api/resolve-gate', {
|
|
197
|
+
org,
|
|
198
|
+
gateId,
|
|
199
|
+
approved,
|
|
200
|
+
resolution,
|
|
201
|
+
resolvedBy: DASHBOARD_RESOLVER,
|
|
202
|
+
});
|
|
203
|
+
if (live) return { delivery: 'live' };
|
|
204
|
+
const fresh = readList(root, org, 'gates.json', 'gates');
|
|
205
|
+
const idx = fresh.findIndex((g) => g.id === gateId);
|
|
206
|
+
if (idx === -1 || fresh[idx].status !== 'pending')
|
|
207
|
+
throw new HilError(409, `gate "${gateId}" was resolved meanwhile`);
|
|
208
|
+
fresh[idx] = {
|
|
209
|
+
...fresh[idx],
|
|
210
|
+
status: approved ? 'approved' : 'rejected',
|
|
211
|
+
resolvedAt: Date.now(),
|
|
212
|
+
resolvedBy: DASHBOARD_RESOLVER,
|
|
213
|
+
...(resolution ? { resolution } : {}),
|
|
214
|
+
};
|
|
215
|
+
writeList(root, org, 'gates.json', 'gates', fresh);
|
|
216
|
+
return { delivery: 'recorded' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function answerQuestion(root, org, questionId, answer) {
|
|
220
|
+
const q = readList(root, org, 'questions.json', 'questions').find(
|
|
221
|
+
(x) => x.questionId === questionId,
|
|
222
|
+
);
|
|
223
|
+
if (!q) throw new HilError(404, `question "${questionId}" not found for org "${org}"`);
|
|
224
|
+
if (q.answer !== null && q.answer !== undefined)
|
|
225
|
+
throw new HilError(409, `question "${questionId}" was already answered`);
|
|
226
|
+
const live = await callDaemon(org, '/api/answer-question', {
|
|
227
|
+
org,
|
|
228
|
+
role: q.role,
|
|
229
|
+
questionId,
|
|
230
|
+
answer,
|
|
231
|
+
resolvedBy: DASHBOARD_RESOLVER,
|
|
232
|
+
});
|
|
233
|
+
if (live) return { delivery: 'live', role: q.role };
|
|
234
|
+
const fresh = readList(root, org, 'questions.json', 'questions');
|
|
235
|
+
const freshQ = fresh.find((x) => x.questionId === questionId);
|
|
236
|
+
if (freshQ && freshQ.answer !== null && freshQ.answer !== undefined)
|
|
237
|
+
throw new HilError(409, `question "${questionId}" was answered meanwhile`);
|
|
238
|
+
// Queue BEFORE marking answered (daemon.answerQuestion's rule): a failed
|
|
239
|
+
// append must leave the question pending and answerable.
|
|
240
|
+
const queued = queueMessage(root, org, {
|
|
241
|
+
fromQualified: 'human',
|
|
242
|
+
toRole: q.role,
|
|
243
|
+
subject: `answer:${questionId}`,
|
|
244
|
+
body: `question: ${q.question}\n\nanswer: ${answer}`,
|
|
245
|
+
ts: Date.now(),
|
|
246
|
+
});
|
|
247
|
+
if (!queued) throw new HilError(500, 'could not queue the answer — it was NOT recorded, retry');
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
const answered = { answer, answeredAt: now, resolvedBy: DASHBOARD_RESOLVER };
|
|
250
|
+
const merged = fresh.some((x) => x.questionId === questionId)
|
|
251
|
+
? fresh.map((x) => (x.questionId === questionId ? { ...x, ...answered } : x))
|
|
252
|
+
: [...fresh, { ...q, ...answered }];
|
|
253
|
+
writeList(root, org, 'questions.json', 'questions', merged);
|
|
254
|
+
return { delivery: 'queued', role: q.role };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Deliver `text` to `role`: live into its mailbox, else into the org's
|
|
258
|
+
* inbox, which the daemon drains when the org next starts. */
|
|
259
|
+
export async function sendHumanMessage(root, org, role, text) {
|
|
260
|
+
const live = await callDaemon(org, '/api/human-message', { org, role, text });
|
|
261
|
+
if (live) return { delivery: 'live' };
|
|
262
|
+
const queued = queueMessage(root, org, {
|
|
263
|
+
fromQualified: 'human',
|
|
264
|
+
toRole: role,
|
|
265
|
+
subject: 'message from human',
|
|
266
|
+
body: text,
|
|
267
|
+
ts: Date.now(),
|
|
268
|
+
});
|
|
269
|
+
if (!queued) throw new HilError(500, 'could not queue the message');
|
|
270
|
+
return { delivery: 'queued' };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Map a thrown error to an HTTP status + message. */
|
|
274
|
+
export function hilErrorStatus(err) {
|
|
275
|
+
return { status: err instanceof HilError ? err.status : 500, error: err.message };
|
|
276
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// What an Org Runtime v2 org is doing, for the dashboard's Runtime pane,
|
|
2
|
+
// assembled from the runtime's own sources rather than the dashboard's
|
|
3
|
+
// derived files:
|
|
4
|
+
// - the live daemon's /api/status (roles, task DAG) when the org runs —
|
|
5
|
+
// read with the org's AGENT credential, which authorizes no decision;
|
|
6
|
+
// - <org>/runtime.json (last run, checkpointed task DAG) when it doesn't;
|
|
7
|
+
// - <org>/<run>/bus.jsonl (per-role usage incl. cache tokens, and the
|
|
8
|
+
// evidence-gate / idle-watchdog audit trail);
|
|
9
|
+
// - <org>/idle-watchdog.json (the idle clock and its hold deadline);
|
|
10
|
+
// - <org>/history.jsonl (how each run actually ended);
|
|
11
|
+
// - the org definition, resolved through cost_tiers like session.ts does.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
// Compiled from orgrt/*.ts, like forwarder.js in routes-org.mjs.
|
|
15
|
+
import { lookupOrg } from '../orgrt/broker.js';
|
|
16
|
+
import { resolveRoleCostTier, validateCostTiers } from '../orgrt/cost-tier.js';
|
|
17
|
+
import { readIdleStatus } from '../orgrt/idle-deadline.js';
|
|
18
|
+
import { OrgDefSchema } from '../orgrt/types.js';
|
|
19
|
+
|
|
20
|
+
const orgDir = (root, org) => path.join(root, '.monomind', 'orgs', org);
|
|
21
|
+
|
|
22
|
+
/** Audit reasons the Runtime pane lists: the completion-evidence gate, the
|
|
23
|
+
* idle watchdog, org_complete refusals and role failures. */
|
|
24
|
+
export const RUNTIME_AUDIT_REASONS = new Set([
|
|
25
|
+
'task-evidence-refused',
|
|
26
|
+
'task-evidence-escalated',
|
|
27
|
+
'org-complete-refused',
|
|
28
|
+
'idle-nudge',
|
|
29
|
+
'idle-stop',
|
|
30
|
+
'no-progress',
|
|
31
|
+
'hold-expired',
|
|
32
|
+
'circuit-breaker-tripped',
|
|
33
|
+
'budget-exhausted',
|
|
34
|
+
'org-budget-exhausted',
|
|
35
|
+
'agent-fatal',
|
|
36
|
+
'worker-crashed',
|
|
37
|
+
'session-result-error',
|
|
38
|
+
'loadout-unresolvable',
|
|
39
|
+
'loadout-mismatch',
|
|
40
|
+
]);
|
|
41
|
+
const MAX_AUDIT = 60;
|
|
42
|
+
|
|
43
|
+
function readJson(p) {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const zeroUsage = () => ({
|
|
52
|
+
tokens: 0,
|
|
53
|
+
tokens_in: 0,
|
|
54
|
+
tokens_out: 0,
|
|
55
|
+
cache_read: 0,
|
|
56
|
+
cache_creation: 0,
|
|
57
|
+
cost_usd: 0,
|
|
58
|
+
turns: 0,
|
|
59
|
+
// Tokens from usage events recorded before the four-way split (ADR-O001
|
|
60
|
+
// D1). Their `tokens` counted input + output only — no cache — so they add
|
|
61
|
+
// to the uncached figure and leave the billable one unknown.
|
|
62
|
+
legacy_tokens: 0,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A run's bus.jsonl reaches tens of MB; parse each version once.
|
|
66
|
+
const runCache = new Map();
|
|
67
|
+
|
|
68
|
+
/** Per-role usage, last activity and the runtime audit trail of one run. */
|
|
69
|
+
export function readRunDigest(root, org, run) {
|
|
70
|
+
const file = path.join(orgDir(root, org), run, 'bus.jsonl');
|
|
71
|
+
let st;
|
|
72
|
+
try {
|
|
73
|
+
st = fs.statSync(file);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const cached = runCache.get(file);
|
|
78
|
+
if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.digest;
|
|
79
|
+
const usage = {};
|
|
80
|
+
const lastActivity = {};
|
|
81
|
+
const audit = [];
|
|
82
|
+
let startedAt = null;
|
|
83
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
84
|
+
if (!line) continue;
|
|
85
|
+
let e;
|
|
86
|
+
try {
|
|
87
|
+
e = JSON.parse(line);
|
|
88
|
+
} catch {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (startedAt === null && typeof e.ts === 'number') startedAt = e.ts;
|
|
92
|
+
if (e.from && typeof e.ts === 'number') lastActivity[e.from] = e.ts;
|
|
93
|
+
if (e.type === 'usage' && e.from) {
|
|
94
|
+
const u = (usage[e.from] ??= zeroUsage());
|
|
95
|
+
const d = e.data ?? {};
|
|
96
|
+
if (d.tokens_in === undefined) {
|
|
97
|
+
u.legacy_tokens += Number(d.tokens) || 0;
|
|
98
|
+
u.cost_usd += Number(d.cost_usd) || 0;
|
|
99
|
+
u.turns += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
u.tokens += Number(d.tokens) || 0;
|
|
103
|
+
u.tokens_in += Number(d.tokens_in) || 0;
|
|
104
|
+
u.tokens_out += Number(d.tokens_out) || 0;
|
|
105
|
+
u.cache_read += Number(d.cache_read) || 0;
|
|
106
|
+
u.cache_creation += Number(d.cache_creation) || 0;
|
|
107
|
+
u.cost_usd += Number(d.cost_usd) || 0;
|
|
108
|
+
u.turns += 1;
|
|
109
|
+
} else if (e.type === 'audit' && RUNTIME_AUDIT_REASONS.has(e.reason)) {
|
|
110
|
+
audit.push({
|
|
111
|
+
ts: e.ts,
|
|
112
|
+
reason: e.reason,
|
|
113
|
+
from: e.from ?? null,
|
|
114
|
+
msg: e.msg ?? '',
|
|
115
|
+
data: e.data ?? null,
|
|
116
|
+
});
|
|
117
|
+
if (audit.length > MAX_AUDIT) audit.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const digest = { startedAt, usage, lastActivity, audit: audit.reverse() };
|
|
121
|
+
runCache.set(file, { size: st.size, mtimeMs: st.mtimeMs, digest });
|
|
122
|
+
return digest;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The live daemon's status for `org`, or null when no daemon hosts it. */
|
|
126
|
+
async function liveStatus(org) {
|
|
127
|
+
const entry = lookupOrg(org);
|
|
128
|
+
if (!entry) return null;
|
|
129
|
+
try {
|
|
130
|
+
const res = await fetch(`${entry.url}/api/status`, {
|
|
131
|
+
headers: entry.credential ? { 'x-monomind-cred': entry.credential } : {},
|
|
132
|
+
signal: AbortSignal.timeout(3000),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) return { unreachable: `daemon answered ${res.status}` };
|
|
135
|
+
const snap = await res.json();
|
|
136
|
+
return (
|
|
137
|
+
(snap.orgs || []).find((o) => o.name === org) ?? {
|
|
138
|
+
unreachable: 'daemon does not list this org',
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return { unreachable: err.message };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Last `n` history.jsonl entries, newest first, trimmed to what a run
|
|
147
|
+
* outcome needs (`closedBy` before `outcome` — see reporting.ts). */
|
|
148
|
+
export function readRecentHistory(root, org, n = 10) {
|
|
149
|
+
let raw;
|
|
150
|
+
try {
|
|
151
|
+
raw = fs.readFileSync(path.join(orgDir(root, org), 'history.jsonl'), 'utf8');
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const out = [];
|
|
156
|
+
for (const line of raw.split('\n').filter(Boolean).slice(-n).reverse()) {
|
|
157
|
+
try {
|
|
158
|
+
const h = JSON.parse(line);
|
|
159
|
+
out.push({
|
|
160
|
+
run: h.run,
|
|
161
|
+
startedAt: h.startedAt ?? null,
|
|
162
|
+
endedAt: h.endedAt ?? null,
|
|
163
|
+
durationMs: h.durationMs ?? null,
|
|
164
|
+
closedBy: h.closedBy ?? null,
|
|
165
|
+
outcome: h.outcome ?? null,
|
|
166
|
+
blocker: h.blocker ?? null,
|
|
167
|
+
blockerDetail: h.blockerDetail ?? null,
|
|
168
|
+
runnableTasksAtStop: h.runnableTasksAtStop ?? 0,
|
|
169
|
+
crashes: h.crashes ?? [],
|
|
170
|
+
totalTokens: h.totalTokens ?? 0,
|
|
171
|
+
totalCostUsd: h.totalCostUsd ?? 0,
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
/* one bad line doesn't hide the rest */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Model and effort a role runs with, with where each came from. Mirrors
|
|
181
|
+
* session.ts: an explicit adapter_config.model beats the tier's model; the
|
|
182
|
+
* tier's effort applies either way. */
|
|
183
|
+
export function describeRoleModel(role, def) {
|
|
184
|
+
let tier = null;
|
|
185
|
+
let tierError = null;
|
|
186
|
+
try {
|
|
187
|
+
tier = resolveRoleCostTier({ role, def }) ?? null;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
tierError = err.message;
|
|
190
|
+
}
|
|
191
|
+
const explicit = role.adapter_config?.model || null;
|
|
192
|
+
return {
|
|
193
|
+
runtime: role.runtime ?? def.runtime ?? 'claude',
|
|
194
|
+
model: explicit ?? tier?.model ?? null,
|
|
195
|
+
modelSource: explicit ? 'adapter_config' : tier ? 'tier' : 'runtime default',
|
|
196
|
+
effort: tier?.effort ?? null,
|
|
197
|
+
tier: tier?.tier ?? null,
|
|
198
|
+
tierError,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function runtimeView(root, org) {
|
|
203
|
+
const raw = readJson(path.join(root, '.monomind', 'orgs', `${org}.json`));
|
|
204
|
+
if (!raw) return null;
|
|
205
|
+
const parsed = OrgDefSchema.safeParse(raw);
|
|
206
|
+
// Defaults (run_config) come from the schema; an invalid definition still
|
|
207
|
+
// renders, with the problems listed.
|
|
208
|
+
const def = parsed.success ? parsed.data : { ...raw, run_config: raw.run_config ?? {} };
|
|
209
|
+
const rc = def.run_config ?? {};
|
|
210
|
+
const runtime = readJson(path.join(orgDir(root, org), 'runtime.json'));
|
|
211
|
+
const live = await liveStatus(org);
|
|
212
|
+
const isLive = !!(live && !live.unreachable);
|
|
213
|
+
const run = (isLive ? live.run : null) ?? runtime?.run ?? null;
|
|
214
|
+
const digest = run ? readRunDigest(root, org, run) : null;
|
|
215
|
+
const liveRoles = new Map((isLive ? live.roles : []).map((r) => [r.id, r]));
|
|
216
|
+
const pendingRoles = new Set(isLive ? live.pendingRoles : []);
|
|
217
|
+
|
|
218
|
+
const roles = (def.roles || []).map((r) => {
|
|
219
|
+
const lr = liveRoles.get(r.id);
|
|
220
|
+
return {
|
|
221
|
+
id: r.id,
|
|
222
|
+
title: r.title || r.id,
|
|
223
|
+
reportsTo: r.reports_to ?? null,
|
|
224
|
+
...describeRoleModel(r, def),
|
|
225
|
+
status: lr?.status ?? (pendingRoles.has(r.id) ? 'not started' : isLive ? 'idle' : 'stopped'),
|
|
226
|
+
usage: digest?.usage[r.id] ?? zeroUsage(),
|
|
227
|
+
lastActivity: digest?.lastActivity[r.id] ?? null,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
const totals = roles.reduce((t, r) => {
|
|
231
|
+
for (const k of Object.keys(t)) t[k] += r.usage[k];
|
|
232
|
+
return t;
|
|
233
|
+
}, zeroUsage());
|
|
234
|
+
const basis = rc.budget_tokens_basis ?? 'uncached';
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
org,
|
|
238
|
+
valid: parsed.success,
|
|
239
|
+
problems: parsed.success
|
|
240
|
+
? []
|
|
241
|
+
: parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
242
|
+
live: isLive,
|
|
243
|
+
liveError: live?.unreachable ?? null,
|
|
244
|
+
run,
|
|
245
|
+
status: isLive ? 'running' : (runtime?.status ?? 'never run'),
|
|
246
|
+
closedBy: isLive ? null : (runtime?.closedBy ?? null),
|
|
247
|
+
startedAt: digest?.startedAt ?? null,
|
|
248
|
+
updated: runtime?.updated ?? null,
|
|
249
|
+
idle: isLive && run ? readIdleStatus(root, org, run) : null,
|
|
250
|
+
settings: {
|
|
251
|
+
completion: rc.completion ?? 'boss',
|
|
252
|
+
completion_evidence: !!rc.completion_evidence,
|
|
253
|
+
max_evidence_attempts: rc.max_evidence_attempts ?? 3,
|
|
254
|
+
idle_minutes: rc.idle_minutes ?? 10,
|
|
255
|
+
session_scope: rc.session_scope ?? 'role',
|
|
256
|
+
workspace: rc.workspace ?? 'repo',
|
|
257
|
+
max_concurrent_agents: rc.max_concurrent_agents ?? 4,
|
|
258
|
+
},
|
|
259
|
+
budget: {
|
|
260
|
+
tokens: rc.budget_tokens ?? null,
|
|
261
|
+
basis,
|
|
262
|
+
// Enforcement counts cache tokens only on 'billable'. Pre-split usage
|
|
263
|
+
// (legacy_tokens) is uncached, so it leaves a billable total unknown.
|
|
264
|
+
used:
|
|
265
|
+
basis === 'billable'
|
|
266
|
+
? totals.legacy_tokens > 0
|
|
267
|
+
? null
|
|
268
|
+
: totals.tokens
|
|
269
|
+
: totals.tokens_in + totals.tokens_out + totals.legacy_tokens,
|
|
270
|
+
},
|
|
271
|
+
totals,
|
|
272
|
+
roles,
|
|
273
|
+
tasks: (isLive ? live.tasks : runtime?.checkpoint?.tasks) ?? [],
|
|
274
|
+
audit: digest?.audit ?? [],
|
|
275
|
+
history: readRecentHistory(root, org),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** run_config keys the dashboard's Config tab may set. Everything else in the
|
|
280
|
+
* definition is left exactly as it is on disk. */
|
|
281
|
+
const EDITABLE_RUN_CONFIG = new Set([
|
|
282
|
+
'max_concurrent_agents',
|
|
283
|
+
'budget_tokens',
|
|
284
|
+
'budget_tokens_basis',
|
|
285
|
+
'idle_minutes',
|
|
286
|
+
'completion',
|
|
287
|
+
'completion_evidence',
|
|
288
|
+
'max_evidence_attempts',
|
|
289
|
+
'session_scope',
|
|
290
|
+
'workspace',
|
|
291
|
+
'memory_namespace',
|
|
292
|
+
'max_turns_per_message',
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
export class ConfigRejected extends Error {
|
|
296
|
+
constructor(problems) {
|
|
297
|
+
super(problems.join('; '));
|
|
298
|
+
this.problems = problems;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Apply `patch` ({ goal?, schedule?, run_config?, cost_tiers_default? }) to
|
|
303
|
+
* the org definition on disk and write it only if the result passes the same
|
|
304
|
+
* OrgDefSchema parse (and cost-tier check) `org run` performs. A `null`
|
|
305
|
+
* run_config value removes that key, so the runtime default applies. Returns
|
|
306
|
+
* the definition as written. */
|
|
307
|
+
export function patchOrgConfig(root, org, patch) {
|
|
308
|
+
const file = path.join(root, '.monomind', 'orgs', `${org}.json`);
|
|
309
|
+
let raw;
|
|
310
|
+
try {
|
|
311
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
312
|
+
} catch (err) {
|
|
313
|
+
throw new ConfigRejected([`cannot read ${file}: ${err.message}`]);
|
|
314
|
+
}
|
|
315
|
+
const problems = [];
|
|
316
|
+
const next = { ...raw };
|
|
317
|
+
if (patch.goal !== undefined) next.goal = String(patch.goal);
|
|
318
|
+
if (patch.schedule !== undefined) next.schedule = patch.schedule === '' ? null : patch.schedule;
|
|
319
|
+
if (patch.run_config !== undefined) {
|
|
320
|
+
const rc = { ...(raw.run_config ?? {}) };
|
|
321
|
+
for (const [k, v] of Object.entries(patch.run_config ?? {})) {
|
|
322
|
+
if (!EDITABLE_RUN_CONFIG.has(k)) {
|
|
323
|
+
problems.push(`run_config.${k} is not editable here`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (v === null) delete rc[k];
|
|
327
|
+
else rc[k] = v;
|
|
328
|
+
}
|
|
329
|
+
next.run_config = rc;
|
|
330
|
+
}
|
|
331
|
+
if (patch.cost_tiers_default !== undefined) {
|
|
332
|
+
const ct = { ...(raw.cost_tiers ?? {}) };
|
|
333
|
+
if (patch.cost_tiers_default) ct.default = String(patch.cost_tiers_default);
|
|
334
|
+
else delete ct.default;
|
|
335
|
+
if (Object.keys(ct).length) next.cost_tiers = ct;
|
|
336
|
+
else delete next.cost_tiers;
|
|
337
|
+
}
|
|
338
|
+
if (problems.length) throw new ConfigRejected(problems);
|
|
339
|
+
const parsed = OrgDefSchema.safeParse(next);
|
|
340
|
+
if (!parsed.success)
|
|
341
|
+
throw new ConfigRejected(
|
|
342
|
+
parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`),
|
|
343
|
+
);
|
|
344
|
+
const tierProblems = validateCostTiers(parsed.data);
|
|
345
|
+
if (tierProblems.length) throw new ConfigRejected(tierProblems);
|
|
346
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
347
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
348
|
+
fs.renameSync(tmp, file);
|
|
349
|
+
return next;
|
|
350
|
+
}
|
package/dist/src/ui/orgs.html
CHANGED
|
@@ -1827,6 +1827,9 @@ async function loadRunHistory() {
|
|
|
1827
1827
|
? `<span style="color:${outcomeColor(h.outcome.status)}">${esc(h.outcome.status.toUpperCase())}</span>
|
|
1828
1828
|
<span style="color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(h.outcome.summary || '')}</span>`
|
|
1829
1829
|
: `<span style="color:var(--dim)">NO OUTCOME</span>`}
|
|
1830
|
+
${h.blocker ? `<span style="color:var(--amber, #d90)">blocker: ${esc(h.blocker)}</span>` : ''}
|
|
1831
|
+
<span style="color:var(--dim);white-space:nowrap" title="How the run actually ended (history closedBy)">${esc(h.closedBy ? `closed by ${h.closedBy}` : 'stopped manually')}</span>
|
|
1832
|
+
${Number(h.runnableTasksAtStop) > 0 ? `<span style="color:var(--amber, #d90);white-space:nowrap">${Number(h.runnableTasksAtStop)} tasks open</span>` : ''}
|
|
1830
1833
|
${Array.isArray(h.crashes) && h.crashes.length ? `<span style="color:var(--red)">✗ ${Number(h.crashes.length)} crash</span>` : ''}
|
|
1831
1834
|
</div>`).join('')}
|
|
1832
1835
|
</div>`;
|
|
@@ -2111,13 +2114,21 @@ async function sendChatMessage() {
|
|
|
2111
2114
|
btn.disabled = true;
|
|
2112
2115
|
input.value = '';
|
|
2113
2116
|
input.style.height = '';
|
|
2117
|
+
let error = null;
|
|
2114
2118
|
try {
|
|
2115
|
-
await fetch(`/api/orgs/${encodeURIComponent(selectedOrg)}/chat`, {
|
|
2119
|
+
const res = await fetch(`/api/orgs/${encodeURIComponent(selectedOrg)}/chat`, {
|
|
2116
2120
|
method: 'POST',
|
|
2117
2121
|
headers: { 'Content-Type': 'application/json' },
|
|
2118
2122
|
body: JSON.stringify({ text }),
|
|
2119
2123
|
});
|
|
2120
|
-
|
|
2124
|
+
const data = await res.json().catch(() => ({}));
|
|
2125
|
+
if (!res.ok || !data.ok) error = data.error || `HTTP ${res.status}`;
|
|
2126
|
+
} catch (e) { error = e.message; }
|
|
2127
|
+
if (error) {
|
|
2128
|
+
// Keep what was typed; say why it did not go out.
|
|
2129
|
+
input.value = text;
|
|
2130
|
+
alert(`Not delivered: ${error}`);
|
|
2131
|
+
}
|
|
2121
2132
|
btn.disabled = false;
|
|
2122
2133
|
input.focus();
|
|
2123
2134
|
}
|
|
@@ -2312,13 +2323,13 @@ function connectSSE() {
|
|
|
2312
2323
|
renderActivity();
|
|
2313
2324
|
}
|
|
2314
2325
|
// Reload org list if start/stop event
|
|
2315
|
-
if (ev.type === 'org:start' || ev.type === 'org:stop') {
|
|
2326
|
+
if (ev.type === 'org:start' || ev.type === 'org:stop' || ev.type === 'org:complete') {
|
|
2316
2327
|
setTimeout(loadOrgs, 400);
|
|
2317
2328
|
}
|
|
2318
2329
|
// Update live strip for selected org on chart tab
|
|
2319
2330
|
if (name === selectedOrg && currentTab === 'chart') {
|
|
2320
2331
|
if (ev.type === 'org:start') orgRunStartMs = Date.now();
|
|
2321
|
-
if (ev.type === 'org:stop') orgRunStartMs = null;
|
|
2332
|
+
if (ev.type === 'org:stop' || ev.type === 'org:complete') orgRunStartMs = null;
|
|
2322
2333
|
appendLiveFeedRow(ev, true);
|
|
2323
2334
|
updateLiveStrip();
|
|
2324
2335
|
}
|