create-pmos 0.240.0 → 0.242.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-report-structure-real-test.js +211 -0
- package/workspace/c3-report-structure-test.js +382 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/notes.md +22 -1
- package/workspace/guide/report-design.md +4 -0
- package/workspace/guide/report-structure.md +91 -0
- package/workspace/guide/reports-dashboards.md +1 -1
- package/workspace/guide/reports-set-up.md +2 -0
- package/workspace/lib/charts-store.js +124 -0
- package/workspace/lib/headless.js +17 -3
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +170 -0
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/notes-screen.js +128 -8
- package/workspace/lib/report-structure-needs.js +64 -0
- package/workspace/lib/report-structure.js +515 -0
- package/workspace/lib/route-modules.js +3 -0
- package/workspace/lib/routes-meeting-focus.js +5 -3
- package/workspace/lib/routes-meeting-material.js +143 -0
- package/workspace/lib/routes-meeting-replay.js +75 -0
- package/workspace/lib/routes-notes.js +37 -4
- package/workspace/lib/routes-report-setups.js +11 -6
- package/workspace/lib/routes-report-structure.js +619 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +15 -3
- package/workspace/public/pm/c3-demo-material.js +58 -0
- package/workspace/public/pm/c3-demo-rd.js +4 -0
- package/workspace/public/pm/c3-demo.js +5 -1
- package/workspace/public/pm/c3-design-studio.js +7 -3
- package/workspace/public/pm/c3-focus.js +83 -16
- package/workspace/public/pm/c3-listen.css +10 -0
- package/workspace/public/pm/c3-material.css +22 -0
- package/workspace/public/pm/c3-material.js +311 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +127 -0
- package/workspace/public/pm/c3-replay.js +413 -0
- package/workspace/public/pm/c3-report-setup.js +3 -0
- package/workspace/public/pm/c3-report-structure.css +40 -0
- package/workspace/public/pm/c3-report-structure.js +455 -0
- package/workspace/public/pm/c3-reports.css +2 -0
- package/workspace/public/pm/c3-reports.js +22 -3
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- package/workspace/public/pm/listen-sheet.js +94 -0
- package/workspace/public/pm/listen-sources.js +82 -0
- package/workspace/public/pm/notes-aside.js +62 -0
- package/workspace/public/pm/notes-screen-capture.js +23 -10
- package/workspace/public/pm/notes-screen-view.js +18 -5
- package/workspace/public/pm/notes-shots.js +55 -0
- package/workspace/public/pm/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- package/workspace/server.js +38 -9
- package/workspace/skills/report-source-check.skill +45 -0
- package/workspace/skills/report-structure-dry.skill +51 -0
- package/workspace/skills/report-structure.skill +61 -0
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/test-lists/meeting-material.json +6 -0
- package/workspace/test-lists/meeting-replay.json +6 -0
- package/workspace/test-lists/mm-capture.json +15 -0
- package/workspace/test-lists/report-structure-backend.json +5 -0
- package/workspace/test-lists/report-structure-ui.json +11 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-report-structure.js — the report structure step's routes, its jobs, the source checks and the draft gate (initiative report-structure).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/report-structure?ws&id → { ok, setup: { id, name, cadence }, structure } (lib/report-structure.js structureAnswer)
|
|
5
|
+
* GET /api/report-structure/version?ws&id&n → { ok, plan }
|
|
6
|
+
* POST /api/report-structure/turn { ws, id, text } → { ok, running: true } starts ONE report-structure job; 409 while one runs
|
|
7
|
+
* POST /api/report-structure/accept { ws, id, n } → { ok, structure, brief, charts: [ids] } "Use this structure": new charts to charts.jsonl, brief.md
|
|
8
|
+
* POST /api/report-structure/revert { ws, id, n } → { ok, structure }
|
|
9
|
+
* POST /api/report-structure/source { ws, id, source } → { ok, structure } S<n+1> = the current plan + the source; a Chrome or computer source starts its check
|
|
10
|
+
* POST /api/report-structure/remove { ws, id, kind, ref } → { ok, structure } S<n+1> without a chart, a source or a section; a fixed section is 400
|
|
11
|
+
* POST /api/report-structure/check { ws, id, source } → { ok, check } starts (or restarts) the check of a Chrome or computer source
|
|
12
|
+
* GET /api/report-structure/check?ws&id&source → { ok, check }
|
|
13
|
+
* POST /api/report-structure/dry { ws, id, n? } → { ok, running: true } starts ONE report-structure-dry job
|
|
14
|
+
* GET /api/report-structure/dry?ws&id&run → { ok, dry }
|
|
15
|
+
* GET /api/charts?ws → { ok, charts: [chart + { users }] }
|
|
16
|
+
* GET /api/charts/one?ws&id → { ok, chart }
|
|
17
|
+
*
|
|
18
|
+
* WHO WRITES. Every POST passes pm-guard requirePagePress, and a request with an agent session header is refused first (403), as
|
|
19
|
+
* routes-report-design.js does. A job's output is data: lib/report-structure.js validates a plan or a dry result before anything lands.
|
|
20
|
+
*
|
|
21
|
+
* THE JOBS. A turn and a dry run start ONE headless run each (lib/headless.js kinds report-structure and report-structure-dry; origins
|
|
22
|
+
* `report-structure:<id>` and `report-structure-dry:<id>`), in the report's folder, with the skill and a block that names the files. A run may write
|
|
23
|
+
* one file, structure/pending/<job>.json, and prints a JSON result. One job per report at a time. A failed, stopped or timed-out run, or output the
|
|
24
|
+
* validator refuses, appends an 'error' line with the reason and changes nothing.
|
|
25
|
+
*
|
|
26
|
+
* THE SOURCE CHECK. A Chrome or computer source is checked by an INTERACTIVE session (no headless kind: the browser tools need a pane) started as
|
|
27
|
+
* Work's approved steps start (server.js STEP_FLAGS: --chrome and an allow rule for the browser tools only), with the skill report-source-check. The
|
|
28
|
+
* session writes checks/pending/<run>.json; the server validates it and writes checks/<source>.json. A computer source starts a session only when
|
|
29
|
+
* the PM's own Claude Code settings allow a computer-use tool; otherwise its `session` step is Needs you and nothing starts.
|
|
30
|
+
* Env PMOS_REPORT_CHECK_STUB ('pass' | 'fail' | a path to a JSON file { default?, <sourceId>? }: 'pass' or 'fail') answers a check without a session,
|
|
31
|
+
* for tests. PMOS_REPORT_CHECK_PLATFORM names the platform for the `mac` step, for tests.
|
|
32
|
+
*
|
|
33
|
+
* THE DRAFT GATE. A scheduled draft of a report with a saved structure (server.js fireSchedule → beforeDraft) re-checks each Chrome or computer
|
|
34
|
+
* source first. When all pass the draft starts at once. When one fails the draft waits (structure/hold.json), report-structure-needs.js shows a Work
|
|
35
|
+
* row "Check <source> for <report>", and sweep() starts the draft when every check passes, or after 4 hours without the failing source
|
|
36
|
+
* (PMOS_REPORT_HOLD_MS), with a note in the draft job's block. The draft never guesses a number.
|
|
37
|
+
*/
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
const os = require('os');
|
|
41
|
+
const guard = require('./pm-guard.js');
|
|
42
|
+
const S = require('./report-structure.js');
|
|
43
|
+
const CS = require('./charts-store.js');
|
|
44
|
+
const RS = require('./report-setups.js');
|
|
45
|
+
const RDraft = require('./report-draft.js');
|
|
46
|
+
const headless = require('./headless.js');
|
|
47
|
+
|
|
48
|
+
const text = (v) => (typeof v === 'string' ? v : '');
|
|
49
|
+
const bad = (status, error) => ({ status, body: { ok: false, error } });
|
|
50
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
51
|
+
const POSTS = ['turn', 'accept', 'revert', 'source', 'remove', 'check', 'dry'];
|
|
52
|
+
const HOLD_MS = 4 * 3600 * 1000;
|
|
53
|
+
const CHECK_MS = 10 * 60 * 1000;
|
|
54
|
+
const STEP_FLAGS_FALLBACK = [{ flag: '--chrome' }, { flag: '--allowedTools', value: 'mcp__claude-in-chrome' }];
|
|
55
|
+
|
|
56
|
+
const jobs = new Map(); // `${root}\0${id}` → { id, kind, since, turn, pending, sessionId, base, n }
|
|
57
|
+
const checkJobs = new Map(); // `${root}\0${id}\0${source}` → { run, since, pending, sessionId, kind }
|
|
58
|
+
let lastCtx = null;
|
|
59
|
+
const keyOf = (root, id) => `${path.resolve(String(root))}\u0000${id}`;
|
|
60
|
+
const checkKey = (root, id, source) => `${keyOf(root, id)}\u0000${source}`;
|
|
61
|
+
|
|
62
|
+
function wsOf(ctx, id) {
|
|
63
|
+
const w = ctx.getWs(id || null);
|
|
64
|
+
return w && (!id || w.id === id) ? w : null;
|
|
65
|
+
}
|
|
66
|
+
function setupOf(root, id) {
|
|
67
|
+
let st = null;
|
|
68
|
+
try { st = RS.stateOf(root); } catch { st = null; }
|
|
69
|
+
const s = st && st.setups.get(id);
|
|
70
|
+
if (s && s.status !== 'removed') return { id: s.id, name: s.name, cadence: (s.schedule && s.schedule.cadence) || s.id, purpose: s.purpose, audience: s.audience, sources: s.sources, sections: s.sections };
|
|
71
|
+
// A report with no setup (a hand-written cadence) can be shaped when its folder exists.
|
|
72
|
+
try { if (fs.existsSync(S.paths(root, id).folder)) return { id, name: id, cadence: id, purpose: '', audience: '', sources: [], sections: [] }; } catch { /* not a report id */ }
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const pub = (s) => ({ id: s.id, name: s.name, cadence: s.cadence });
|
|
76
|
+
function runningOf(root, id) {
|
|
77
|
+
const j = jobs.get(keyOf(root, id));
|
|
78
|
+
return j ? { since: j.since, turn: j.turn, kind: j.kind } : null;
|
|
79
|
+
}
|
|
80
|
+
const answerOf = (root, id) => S.structureAnswer(root, id, { running: runningOf(root, id) });
|
|
81
|
+
const storeIds = (root) => new Set(CS.stateOf(root).keys());
|
|
82
|
+
|
|
83
|
+
// ── the job ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
84
|
+
function resultJson(t) {
|
|
85
|
+
const s = String(t || '').trim();
|
|
86
|
+
const tries = [s];
|
|
87
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/i.exec(s);
|
|
88
|
+
if (fence) tries.push(fence[1].trim());
|
|
89
|
+
const a = s.indexOf('{'); const b = s.lastIndexOf('}');
|
|
90
|
+
if (a >= 0 && b > a) tries.push(s.slice(a, b + 1));
|
|
91
|
+
for (const x of tries) { try { const o = JSON.parse(x); if (isObj(o)) return o; } catch { /* next */ } }
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function promptFor(skill, { kind, root, id, setup, st, turnText, pending, n }) {
|
|
96
|
+
const p = S.paths(root, id);
|
|
97
|
+
const lines = [`- Mode: ${kind === 'dry' ? 'dry' : 'turn'}`, `- Report: ${setup.name} (${id})`, `- Today: ${new Date().toISOString().slice(0, 10)}`,
|
|
98
|
+
`- Purpose on file: ${setup.purpose ? String(setup.purpose).replace(/\s+/g, ' ') : 'none'}`, `- Audience on file: ${setup.audience ? String(setup.audience).replace(/\s+/g, ' ') : 'none'}`,
|
|
99
|
+
`- Schedule: ${setup.cadence}`, `- Repository: ${p.abs}`, `- Chart store: ${CS.paths(root).file}`];
|
|
100
|
+
const cur = kind === 'dry' ? n : st.current;
|
|
101
|
+
if (kind === 'dry') {
|
|
102
|
+
lines.push(`- Version to try: S${n} (${path.join(p.versions, `S${n}.json`)})`);
|
|
103
|
+
} else {
|
|
104
|
+
lines.push(`- Turn: ${turnText ? turnText.replace(/\s+/g, ' ') : 'none'}`);
|
|
105
|
+
lines.push(`- Previous version: ${cur != null ? path.join(p.versions, `S${cur}.json`) : 'none (this is the first version)'}`);
|
|
106
|
+
lines.push(`- Version number to make: S${S.nextN(st)}`);
|
|
107
|
+
}
|
|
108
|
+
lines.push(`- Output path: ${pending}`);
|
|
109
|
+
return `${skill}\n\nThis job:\n${lines.join('\n')}\n\nWrite the JSON to the output path, then print the JSON result. Send nothing.\r`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Start a job. spec: { kind: 'turn' | 'dry', turnText?, n? }. → { ok, running } | { ok: false, status, error } */
|
|
113
|
+
function startJob(ctx, w, id, spec) {
|
|
114
|
+
const root = w.root;
|
|
115
|
+
const key = keyOf(root, id);
|
|
116
|
+
if (jobs.has(key)) return { ok: false, status: 409, error: 'PMOS is already working on this report. Wait for the answer, then try again.' };
|
|
117
|
+
if (typeof ctx.spawnSession !== 'function') return { ok: false, status: 409, error: 'this workspace cannot start background runs' };
|
|
118
|
+
const kindName = spec.kind === 'dry' ? 'report-structure-dry' : 'report-structure';
|
|
119
|
+
const origin = `${kindName}:${id}`;
|
|
120
|
+
if (headless.kindFor({ agent: 'claude', origin }) !== kindName) return { ok: false, status: 409, error: 'Background runs are switched off here (PMOS_HEADLESS=off), so PMOS cannot work on the structure.' };
|
|
121
|
+
const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt(kindName, w) : null;
|
|
122
|
+
if (!skill) return { ok: false, status: 500, error: `the ${kindName} skill is not installed` };
|
|
123
|
+
const setup = setupOf(root, id) || { id, name: id, cadence: id };
|
|
124
|
+
const st = S.stateOf(root, id);
|
|
125
|
+
const p = S.paths(root, id);
|
|
126
|
+
const run = S.newRun();
|
|
127
|
+
const pending = path.join(p.pending, `${run}.json`);
|
|
128
|
+
try { fs.mkdirSync(p.pending, { recursive: true }); } catch (e) { return { ok: false, status: 500, error: 'could not make the pending folder: ' + String((e && e.code) || e) }; }
|
|
129
|
+
const job = { id: run, kind: spec.kind, since: new Date().toISOString(), turn: spec.kind === 'dry' ? `Trying S${spec.n} with real data` : (spec.turnText || 'First version'), pending, sessionId: null, base: st.current, n: spec.n || null, kindName };
|
|
130
|
+
jobs.set(key, job);
|
|
131
|
+
let s = null;
|
|
132
|
+
try {
|
|
133
|
+
s = ctx.spawnSession({ name: spec.kind === 'dry' ? 'Report structure · real data' : 'Report structure', agent: 'claude', ws: w.id, origin, cwd: p.rel,
|
|
134
|
+
initialInput: promptFor(skill, { kind: spec.kind, root, id, setup, st, turnText: spec.turnText, pending, n: spec.n }), writePaths: [pending], onDone: (r) => finish(w, id, job, r) });
|
|
135
|
+
} catch (e) { s = { state: 'exited', note: String((e && e.message) || e) }; }
|
|
136
|
+
if (!s || s.state === 'exited' || s.headless !== kindName) {
|
|
137
|
+
jobs.delete(key);
|
|
138
|
+
if (s && s.headless !== kindName && s.state !== 'exited' && typeof ctx.stopSession === 'function') { try { ctx.stopSession(s.id); } catch { /* best effort */ } }
|
|
139
|
+
return { ok: false, status: 409, error: 'PMOS could not start the run: ' + ((s && s.note) || 'no background run') };
|
|
140
|
+
}
|
|
141
|
+
job.sessionId = s.id;
|
|
142
|
+
return { ok: true, running: true };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function finish(w, id, job, r) {
|
|
146
|
+
const root = w.root;
|
|
147
|
+
const key = keyOf(root, id);
|
|
148
|
+
if (jobs.get(key) === job) jobs.delete(key);
|
|
149
|
+
const mode = job.kind === 'dry' ? 'dry' : 'turn';
|
|
150
|
+
const fail = (why) => {
|
|
151
|
+
try {
|
|
152
|
+
if (mode === 'dry') S.append(root, id, { ev: 'dry', run: job.id, n: job.n, ok: false });
|
|
153
|
+
S.append(root, id, { ev: 'error', text: why, mode });
|
|
154
|
+
} catch { /* the folder is gone */ }
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
const res = (r && r.result) || {};
|
|
158
|
+
const ok = !!(r && !r.timedOut && !r.stopped && r.code === 0 && res.ok && !res.isError);
|
|
159
|
+
const out = resultJson(res.text);
|
|
160
|
+
let raw = null;
|
|
161
|
+
try { raw = fs.readFileSync(job.pending, 'utf8'); } catch { raw = null; }
|
|
162
|
+
let data = null;
|
|
163
|
+
if (raw) { try { data = JSON.parse(raw); } catch { data = null; } }
|
|
164
|
+
if (!data && out && isObj(out.plan) && mode === 'turn') data = out.plan;
|
|
165
|
+
if (!data && out && isObj(out.dry) && mode === 'dry') data = out.dry;
|
|
166
|
+
const what = mode === 'dry' ? 'real-data run' : 'structure run';
|
|
167
|
+
if (!ok && !data) {
|
|
168
|
+
fail(r && r.timedOut ? `The ${what} took too long and was stopped (${r.note || 'timed out'}).` : r && r.stopped ? `The ${what} was stopped.`
|
|
169
|
+
: `The ${what} failed: ${(r && r.note) || 'no result'}${res.text ? '. ' + String(res.text).replace(/\s+/g, ' ').slice(0, 300) : ''}`);
|
|
170
|
+
} else if (!data) fail(`The ${what} ended without a result. Nothing changed.`);
|
|
171
|
+
else if (mode === 'turn') {
|
|
172
|
+
const v = S.landVersion(root, id, data, { turn: job.turn, answer: out && typeof out.answer === 'string' ? out.answer : null, questions: out && out.questions, options: out && out.options, from: 'turn', charts: storeIds(root) });
|
|
173
|
+
if (!v.ok) fail(`The plan could not be used: ${v.error}. Nothing changed.`);
|
|
174
|
+
} else {
|
|
175
|
+
const plan = S.readVersion(root, id, job.n);
|
|
176
|
+
const d = plan ? S.validateDry({ n: job.n, ...data }, plan) : { ok: false, error: 'the version is gone' };
|
|
177
|
+
if (!d.ok) fail(`The real-data result could not be used: ${d.error}. Nothing was saved.`);
|
|
178
|
+
else {
|
|
179
|
+
S.writeDry(root, id, job.id, d.dry);
|
|
180
|
+
S.append(root, id, { ev: 'dry', run: job.id, n: job.n, ok: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (e) { fail('The run result could not be read: ' + String((e && e.message) || e).slice(0, 200)); }
|
|
184
|
+
try { fs.unlinkSync(job.pending); } catch { /* not written */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A turn that has no answer and no job in this server (the workspace restarted mid-run) gets an error line. */
|
|
188
|
+
function settleStale(root, id) {
|
|
189
|
+
if (jobs.has(keyOf(root, id))) return;
|
|
190
|
+
const ev = S.readEvents(root, id);
|
|
191
|
+
let lastTurn = -1; let lastEnd = -1;
|
|
192
|
+
ev.forEach((l, i) => { if (l.ev === 'turn') lastTurn = i; if (l.ev === 'version' || l.ev === 'error') lastEnd = i; });
|
|
193
|
+
if (lastTurn > lastEnd && Date.now() - Date.parse(ev[lastTurn].at) > 90000) S.append(root, id, { ev: 'error', text: 'The structure run stopped when the workspace restarted. Send the turn again.', mode: 'turn' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── the source checks ───────────────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
/** The settings files Claude Code reads for this repository: the user's, the project's and the project's local file. */
|
|
198
|
+
function claudeSettings(root) {
|
|
199
|
+
const files = [path.join(os.homedir(), '.claude', 'settings.json'), path.join(path.resolve(String(root)), '.claude', 'settings.json'), path.join(path.resolve(String(root)), '.claude', 'settings.local.json')];
|
|
200
|
+
const out = [];
|
|
201
|
+
for (const f of files) { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); if (isObj(o)) out.push(o); } catch { /* none */ } }
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
/** The allow rule that lets a session use computer use, from the PM's own settings, or null. Only a plain `mcp__<server>` rule is passed on. */
|
|
205
|
+
function computerRule(root) {
|
|
206
|
+
for (const s of claudeSettings(root)) {
|
|
207
|
+
const allow = isObj(s.permissions) && Array.isArray(s.permissions.allow) ? s.permissions.allow : [];
|
|
208
|
+
for (const rule of allow) if (typeof rule === 'string' && /^mcp__[A-Za-z0-9_-]*(?:computer|desktop)[A-Za-z0-9_-]*(?:__[A-Za-z0-9_-]+)?$/i.test(rule)) return rule;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const platformOf = (ctx) => String((ctx && ctx.platform) || process.env.PMOS_REPORT_CHECK_PLATFORM || process.platform);
|
|
213
|
+
|
|
214
|
+
function sourceOf(root, id, sourceId, planOverride) {
|
|
215
|
+
const st = S.stateOf(root, id);
|
|
216
|
+
const plan = planOverride || (st.current != null ? S.readVersion(root, id, st.current) : null);
|
|
217
|
+
const src = plan && plan.sources.find((s) => s.id === sourceId);
|
|
218
|
+
return src ? { src, plan } : null;
|
|
219
|
+
}
|
|
220
|
+
const stepsOf = (kind) => S.CHECK_STEPS[kind].map(([sid, label]) => ({ id: sid, label, state: 'waiting', reason: '' }));
|
|
221
|
+
|
|
222
|
+
function stubFor(sourceId) {
|
|
223
|
+
const v = String(process.env.PMOS_REPORT_CHECK_STUB || '').trim();
|
|
224
|
+
if (!v) return null;
|
|
225
|
+
if (v === 'pass' || v === 'fail') return v;
|
|
226
|
+
try {
|
|
227
|
+
const o = JSON.parse(fs.readFileSync(v, 'utf8'));
|
|
228
|
+
const x = isObj(o) ? (o[sourceId] || o.default) : null;
|
|
229
|
+
return x === 'pass' || x === 'fail' ? x : 'pass';
|
|
230
|
+
} catch { return 'pass'; }
|
|
231
|
+
}
|
|
232
|
+
function stubSteps(kind, name, how) {
|
|
233
|
+
const steps = stepsOf(kind).map((s) => ({ ...s, state: 'works' }));
|
|
234
|
+
if (how === 'fail') {
|
|
235
|
+
const at = steps.findIndex((s) => s.id === 'signed-in');
|
|
236
|
+
steps[at] = { ...steps[at], state: 'needs-you', reason: `You are not signed in to ${name}. Sign in, then check again.` };
|
|
237
|
+
steps[at + 1] = { ...steps[at + 1], state: 'waiting', reason: '' };
|
|
238
|
+
}
|
|
239
|
+
return steps;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Write the result of a check (a session's answer, a stub or a failure). `fixed` steps (the server's own) win over what the session wrote. */
|
|
243
|
+
function finishCheck(root, id, sourceId, kind, steps, { endSession, job } = {}) {
|
|
244
|
+
const ok = steps.every((s) => s.state === 'works');
|
|
245
|
+
const check = { source: sourceId, kind, at: new Date().toISOString(), running: false, ok, steps };
|
|
246
|
+
S.writeCheck(root, id, check);
|
|
247
|
+
S.append(root, id, { ev: 'check', source: sourceId, ok, steps });
|
|
248
|
+
const key = checkKey(root, id, sourceId);
|
|
249
|
+
const cur = checkJobs.get(key);
|
|
250
|
+
if (cur && (!job || cur === job)) {
|
|
251
|
+
checkJobs.delete(key);
|
|
252
|
+
if (cur.sessionId && typeof endSession === 'function') { try { endSession(cur.sessionId); } catch { /* best effort */ } }
|
|
253
|
+
}
|
|
254
|
+
if (cur && cur.pending) { try { fs.unlinkSync(cur.pending); } catch { /* none */ } }
|
|
255
|
+
return check;
|
|
256
|
+
}
|
|
257
|
+
function failSteps(kind, stepId, reason, base) {
|
|
258
|
+
const steps = (base || stepsOf(kind)).map((s) => ({ ...s }));
|
|
259
|
+
const at = steps.findIndex((s) => s.id === stepId);
|
|
260
|
+
if (at >= 0) steps[at] = { ...steps[at], state: 'needs-you', reason };
|
|
261
|
+
for (let i = at + 1; i < steps.length; i++) if (steps[i].state !== 'works') steps[i] = { ...steps[i], state: 'waiting', reason: '' };
|
|
262
|
+
return steps;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Start (or restart) the check of one Chrome or computer source. → { ok, check } | refusal. Never throws. */
|
|
266
|
+
function startCheck(ctx, w, id, sourceId, { plan } = {}) {
|
|
267
|
+
try {
|
|
268
|
+
const root = w.root;
|
|
269
|
+
const f = sourceOf(root, id, sourceId, plan);
|
|
270
|
+
if (!f) return { ok: false, status: 404, error: 'no such source in this structure' };
|
|
271
|
+
const kind = f.src.kind;
|
|
272
|
+
if (kind !== 'chrome' && kind !== 'computer') return { ok: false, status: 400, error: 'only a Chrome or a computer source has a check' };
|
|
273
|
+
const key = checkKey(root, id, sourceId);
|
|
274
|
+
if (checkJobs.has(key)) return { ok: false, status: 409, error: 'this source is being checked now' };
|
|
275
|
+
const base = stepsOf(kind);
|
|
276
|
+
const writeRunning = (steps) => S.writeCheck(root, id, { source: sourceId, kind, at: new Date().toISOString(), running: true, ok: false, steps });
|
|
277
|
+
const stub = stubFor(sourceId);
|
|
278
|
+
// the server's own step: PMOS runs on this Mac (a stub stands in for the Mac and the session)
|
|
279
|
+
const mac = stub || platformOf(ctx) === 'darwin' ? { state: 'works', reason: '' } : { state: 'needs-you', reason: 'PMOS can read a Chrome page or an app only on a Mac. This computer is not one.' };
|
|
280
|
+
base[0] = { ...base[0], ...mac };
|
|
281
|
+
if (mac.state !== 'works') return { ok: true, check: finishCheck(root, id, sourceId, kind, base) };
|
|
282
|
+
// computer use is only used when the PM's own Claude Code settings allow a computer-use tool; otherwise nothing starts (a stub does not change that)
|
|
283
|
+
const rule = kind === 'computer' ? computerRule(root) : null;
|
|
284
|
+
if (kind === 'computer' && !rule) return { ok: true, check: finishCheck(root, id, sourceId, kind, failSteps(kind, 'session', 'Computer use is not available in your Claude Code settings', base)) };
|
|
285
|
+
if (stub) {
|
|
286
|
+
const run = S.newRun();
|
|
287
|
+
const job = { run, since: Date.now(), pending: null, sessionId: null, kind };
|
|
288
|
+
checkJobs.set(key, job);
|
|
289
|
+
writeRunning(base);
|
|
290
|
+
const ms = Number(process.env.PMOS_REPORT_CHECK_STUB_MS) >= 0 ? Number(process.env.PMOS_REPORT_CHECK_STUB_MS) : 150;
|
|
291
|
+
const t = setTimeout(() => { try { finishCheck(root, id, sourceId, kind, stubSteps(kind, f.src.name, stub), { job }); } catch { /* the folder is gone */ } }, ms);
|
|
292
|
+
if (t.unref) t.unref();
|
|
293
|
+
return { ok: true, check: S.readCheck(root, id, sourceId) };
|
|
294
|
+
}
|
|
295
|
+
let flags = null;
|
|
296
|
+
if (kind === 'chrome') flags = Array.isArray(ctx.stepFlags) ? ctx.stepFlags : STEP_FLAGS_FALLBACK;
|
|
297
|
+
else flags = [{ flag: '--allowedTools', value: rule }];
|
|
298
|
+
if (typeof ctx.spawnSession !== 'function') return { ok: true, check: finishCheck(root, id, sourceId, kind, failSteps(kind, 'session', 'This workspace cannot start a session', base)) };
|
|
299
|
+
const skill = typeof ctx.skillPrompt === 'function' ? ctx.skillPrompt('report-source-check', w) : null;
|
|
300
|
+
if (!skill) return { ok: true, check: finishCheck(root, id, sourceId, kind, failSteps(kind, 'session', 'The report-source-check skill is not installed', base)) };
|
|
301
|
+
const p = S.paths(root, id);
|
|
302
|
+
const run = S.newRun();
|
|
303
|
+
const pending = path.join(p.checksPending, `${run}.json`);
|
|
304
|
+
fs.mkdirSync(p.checksPending, { recursive: true });
|
|
305
|
+
const users = f.plan.charts.filter((c) => c.source === sourceId);
|
|
306
|
+
const lines = [`- Source: ${f.src.name} (${sourceId})`, `- Kind: ${kind === 'chrome' ? 'chrome (a page read in Claude in Chrome)' : 'computer (an app on this Mac, computer use)'}`,
|
|
307
|
+
`- ${kind === 'chrome' ? 'Address' : 'App'}: ${f.src.ref || 'not said'}`, `- Report: ${(setupOf(root, id) || { name: id }).name} (${id})`,
|
|
308
|
+
`- What it is used for: ${f.src.use || 'not said'}`,
|
|
309
|
+
`- Numbers to read: ${users.length ? users.map((c) => `${c.name || c.id} (${c.from || 'not said'}; ${c.how || 'not said'})`).join(' | ') : 'none named yet'}`,
|
|
310
|
+
`- Step ids to report: ${S.CHECK_STEPS[kind].map(([sid]) => sid).join(', ')}`, `- Output path: ${pending}`];
|
|
311
|
+
const job = { run, since: Date.now(), pending, sessionId: null, kind };
|
|
312
|
+
checkJobs.set(key, job);
|
|
313
|
+
writeRunning(base);
|
|
314
|
+
let s = null;
|
|
315
|
+
try {
|
|
316
|
+
s = ctx.spawnSession({ name: 'Report source check', agent: 'claude', ws: w.id, origin: `report-source-check:${id}:${sourceId}`, cwd: p.rel,
|
|
317
|
+
initialInput: `${skill}\n\nThis job:\n${lines.join('\n')}\n\nWrite the JSON to the output path, then close your tab and stop. Send and change nothing.\r`, launchFlags: flags });
|
|
318
|
+
} catch (e) { s = { state: 'exited', note: String((e && e.message) || e) }; }
|
|
319
|
+
if (!s || s.state === 'exited') return { ok: true, check: finishCheck(root, id, sourceId, kind, failSteps(kind, 'session', `A session could not start: ${(s && s.note) || 'no session'}`, base), { job }) };
|
|
320
|
+
job.sessionId = s.id;
|
|
321
|
+
return { ok: true, check: S.readCheck(root, id, sourceId) };
|
|
322
|
+
} catch (e) { return { ok: false, status: 500, error: String((e && e.message) || e) }; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Collect the answers of the check sessions of one report; fail a check that took too long or whose server restarted. */
|
|
326
|
+
function settleChecks(ctx, w, id) {
|
|
327
|
+
const root = w.root;
|
|
328
|
+
const pre = `${keyOf(root, id)}\u0000`;
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
for (const [k, job] of [...checkJobs.entries()]) {
|
|
331
|
+
if (!k.startsWith(pre)) continue;
|
|
332
|
+
const sourceId = k.slice(pre.length);
|
|
333
|
+
if (!job.pending) continue; // a stub finishes by itself
|
|
334
|
+
const cur = S.readCheck(root, id, sourceId);
|
|
335
|
+
const base = cur && Array.isArray(cur.steps) ? cur.steps : stepsOf(job.kind);
|
|
336
|
+
let raw = null; let age = 0;
|
|
337
|
+
try { raw = fs.readFileSync(job.pending, 'utf8'); age = now - fs.statSync(job.pending).mtimeMs; } catch { raw = null; }
|
|
338
|
+
if (raw != null) {
|
|
339
|
+
let data = null; try { data = JSON.parse(raw); } catch { data = null; }
|
|
340
|
+
if (!data && age < 20000) continue; // still being written
|
|
341
|
+
const v = data ? S.validateCheck(data, job.kind) : { ok: false, error: 'the check result is not JSON' };
|
|
342
|
+
if (v.ok) {
|
|
343
|
+
const steps = v.steps.map((s) => (s.id === 'mac' ? base.find((b) => b.id === 'mac') || s : s)); // the server's own step wins
|
|
344
|
+
finishCheck(root, id, sourceId, job.kind, steps, { endSession: ctx && ctx.endSession, job });
|
|
345
|
+
} else finishCheck(root, id, sourceId, job.kind, failSteps(job.kind, 'read', `The check answered something PMOS could not use: ${v.error}`, base), { endSession: ctx && ctx.endSession, job });
|
|
346
|
+
} else if (now - job.since > CHECK_MS) {
|
|
347
|
+
finishCheck(root, id, sourceId, job.kind, failSteps(job.kind, 'session', 'The check session did not answer in 10 minutes. Check again.', base), { endSession: ctx && ctx.endSession, job });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// a check marked running that no job of this server holds (the workspace restarted) is over
|
|
351
|
+
for (const [sourceId, c] of Object.entries(S.readChecks(root, id))) {
|
|
352
|
+
if (c.running && !checkJobs.has(checkKey(root, id, sourceId)) && now - Date.parse(c.at) > 90000) {
|
|
353
|
+
try { finishCheck(root, id, sourceId, c.kind, failSteps(c.kind, 'session', 'The check stopped when the workspace restarted. Check again.', c.steps)); } catch { /* gone */ }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── the draft gate ──────────────────────────────────────────────────────────────────────────────────────
|
|
359
|
+
const holdFile = (root, id) => path.join(S.paths(root, id).dir, 'hold.json');
|
|
360
|
+
function readHold(root, id) { try { const o = JSON.parse(fs.readFileSync(holdFile(root, id), 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
|
|
361
|
+
const holdMs = () => (Number(process.env.PMOS_REPORT_HOLD_MS) >= 0 && process.env.PMOS_REPORT_HOLD_MS !== undefined && process.env.PMOS_REPORT_HOLD_MS !== '' ? Number(process.env.PMOS_REPORT_HOLD_MS) : HOLD_MS);
|
|
362
|
+
|
|
363
|
+
/** The saved plan and its Chrome and computer sources, or null when the report has no saved structure. */
|
|
364
|
+
function savedPlan(root, id) {
|
|
365
|
+
let st; try { st = S.stateOf(root, id); } catch { return null; }
|
|
366
|
+
if (!st.saved) return null;
|
|
367
|
+
const plan = S.readVersion(root, id, st.saved.n);
|
|
368
|
+
return plan ? { n: st.saved.n, plan, outside: plan.sources.filter((s) => (s.kind === 'chrome' || s.kind === 'computer') && s.state !== 'off') } : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The block the report-template draft job gets for a report with a saved structure (contract section 8), or '' for none.
|
|
373
|
+
* `note` says which sources could not be read when the draft goes ahead without them.
|
|
374
|
+
*/
|
|
375
|
+
function draftBlock(root, id, { note } = {}) {
|
|
376
|
+
const sp = savedPlan(root, id);
|
|
377
|
+
if (!sp) return '';
|
|
378
|
+
const p = S.paths(root, id);
|
|
379
|
+
const lines = ['This report has a saved structure. Draft it from the structure:',
|
|
380
|
+
`- Brief: ${p.brief}`, `- Saved plan: ${path.join(p.versions, `S${sp.n}.json`)}`,
|
|
381
|
+
`- Data file: write <slot>.data.json next to the draft (${p.folder}), as the skill says`];
|
|
382
|
+
if (note) lines.push(`- Sources that could not be read this cycle: ${note}. Draft without them. Under Sources, list each as not reached with that reason. Never guess a number for them.`);
|
|
383
|
+
return `\n\n${lines.join('\n')}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Before a scheduled draft: re-check each Chrome or computer source of the saved structure. → { hold: false, block } to draft now, or { hold: true }
|
|
388
|
+
* when a check is running or failed (sweep() starts the draft later). A report with no such source drafts now.
|
|
389
|
+
*/
|
|
390
|
+
function beforeDraft(ctx, w, id, sched) {
|
|
391
|
+
try {
|
|
392
|
+
const root = w.root;
|
|
393
|
+
const sp = savedPlan(root, id);
|
|
394
|
+
if (!sp) return { hold: false, block: '' };
|
|
395
|
+
if (!sp.outside.length) return { hold: false, block: draftBlock(root, id) };
|
|
396
|
+
if (readHold(root, id)) return { hold: true };
|
|
397
|
+
for (const src of sp.outside) {
|
|
398
|
+
startCheck(ctx, w, id, src.id, { plan: sp.plan }); // a check already running (409) is the one this draft waits for
|
|
399
|
+
}
|
|
400
|
+
fs.mkdirSync(S.paths(root, id).dir, { recursive: true });
|
|
401
|
+
fs.writeFileSync(holdFile(root, id), JSON.stringify({ since: new Date().toISOString(), sched: sched ? sched.id : null, sources: sp.outside.map((s) => s.id), saved: sp.n }) + '\n');
|
|
402
|
+
return { hold: true };
|
|
403
|
+
} catch (e) { return { hold: false, block: '' }; }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Start the held drafts whose checks are done, or whose 4 hours are up. ctx.fireDraft(schedId, { note }) starts the draft. → the ids released */
|
|
407
|
+
function sweepHolds(ctx, w) {
|
|
408
|
+
const released = [];
|
|
409
|
+
let base;
|
|
410
|
+
try { base = path.join(path.resolve(w.root), RDraft.stateRel(w.root), 'reports'); } catch { return released; }
|
|
411
|
+
let ids = []; try { ids = fs.readdirSync(base); } catch { return released; }
|
|
412
|
+
for (const id of ids) {
|
|
413
|
+
if (!S.CADENCE_RE.test(id) || id === 'definitions') continue;
|
|
414
|
+
const hold = readHold(w.root, id);
|
|
415
|
+
if (!hold) continue;
|
|
416
|
+
try { settleChecks(ctx, w, id); } catch { /* next tick */ }
|
|
417
|
+
const since = Date.parse(hold.since);
|
|
418
|
+
const checks = S.readChecks(w.root, id);
|
|
419
|
+
const wanted = (Array.isArray(hold.sources) ? hold.sources : []).map((sid) => ({ sid, c: checks[sid] }));
|
|
420
|
+
const done = wanted.every((x) => x.c && !x.c.running && Date.parse(x.c.at) >= since - 1000);
|
|
421
|
+
const failing = wanted.filter((x) => x.c && !x.c.running && !x.c.ok);
|
|
422
|
+
const expired = Date.now() - since >= holdMs();
|
|
423
|
+
if (!(done && !failing.length) && !expired) continue;
|
|
424
|
+
let note = null;
|
|
425
|
+
if (failing.length || (expired && !done)) {
|
|
426
|
+
const sp = savedPlan(w.root, id);
|
|
427
|
+
const nameOf = (sid) => (sp && sp.plan.sources.find((s) => s.id === sid) || { name: sid }).name;
|
|
428
|
+
const bad = wanted.filter((x) => !x.c || x.c.running || !x.c.ok);
|
|
429
|
+
note = bad.map((x) => {
|
|
430
|
+
const step = x.c && !x.c.running ? (x.c.steps || []).find((s) => s.state === 'needs-you') : null;
|
|
431
|
+
return `${nameOf(x.sid)} (${step ? step.reason || step.label : 'the check did not finish'})`;
|
|
432
|
+
}).join('; ');
|
|
433
|
+
}
|
|
434
|
+
try { fs.unlinkSync(holdFile(w.root, id)); } catch { /* gone */ }
|
|
435
|
+
try { if (typeof ctx.fireDraft === 'function') ctx.fireDraft(hold.sched, { note, id, ws: w.id }); } catch { /* the schedule is gone */ }
|
|
436
|
+
released.push(id);
|
|
437
|
+
}
|
|
438
|
+
return released;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** On the server tick: collect check answers, release held drafts. */
|
|
442
|
+
function sweep(ctx) {
|
|
443
|
+
if (!ctx) return [];
|
|
444
|
+
lastCtx = ctx;
|
|
445
|
+
const out = [];
|
|
446
|
+
const list = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [ctx.getWs(null)].filter(Boolean);
|
|
447
|
+
for (const w of list) {
|
|
448
|
+
if (!w || !w.root) continue;
|
|
449
|
+
try { out.push(...sweepHolds(ctx, w)); } catch { /* next tick */ }
|
|
450
|
+
}
|
|
451
|
+
for (const k of [...checkJobs.keys()]) {
|
|
452
|
+
const [root, id] = k.split('\u0000');
|
|
453
|
+
const w = list.find((x) => x && x.root && path.resolve(x.root) === root);
|
|
454
|
+
if (w) { try { settleChecks(ctx, w, id); } catch { /* next tick */ } }
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── the handler ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
460
|
+
async function handle(req, res, url, ctx) {
|
|
461
|
+
lastCtx = ctx;
|
|
462
|
+
const p = url.pathname;
|
|
463
|
+
const send = (r) => { ctx.json(res, r.body, r.status); return true; };
|
|
464
|
+
const isCharts = p === '/api/charts' || p.startsWith('/api/charts/');
|
|
465
|
+
const sub = isCharts ? p.replace(/^\/api\/charts\/?/, '') : p.replace(/^\/api\/report-structure\/?/, '');
|
|
466
|
+
|
|
467
|
+
if (req.method === 'GET') {
|
|
468
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
469
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
470
|
+
const nameOf = (cadence) => { const s = setupOf(w.root, cadence); return s ? s.name : cadence; };
|
|
471
|
+
if (isCharts) {
|
|
472
|
+
if (sub === '') return send({ status: 200, body: { ok: true, charts: CS.list(w.root, { runtimeDir: ctx.runtimeDir, nameOf }) } });
|
|
473
|
+
if (sub === 'one') {
|
|
474
|
+
const id = text(url.searchParams.get('id'));
|
|
475
|
+
const c = CS.ID_RE.test(id) ? CS.one(w.root, id, { runtimeDir: ctx.runtimeDir, nameOf }) : null;
|
|
476
|
+
return send(c ? { status: 200, body: { ok: true, chart: c } } : bad(404, 'no such chart'));
|
|
477
|
+
}
|
|
478
|
+
return send(bad(404, 'no such route'));
|
|
479
|
+
}
|
|
480
|
+
const id = text(url.searchParams.get('id'));
|
|
481
|
+
if (!S.CADENCE_RE.test(id)) return send(bad(400, 'id must be a report id'));
|
|
482
|
+
const setup = setupOf(w.root, id);
|
|
483
|
+
if (!setup) return send(bad(404, 'no such report'));
|
|
484
|
+
if (sub === '') {
|
|
485
|
+
try { settleStale(w.root, id); settleChecks(ctx, w, id); } catch { /* a read never fails on this */ }
|
|
486
|
+
return send({ status: 200, body: { ok: true, setup: pub(setup), structure: answerOf(w.root, id) } });
|
|
487
|
+
}
|
|
488
|
+
if (sub === 'version') {
|
|
489
|
+
const n = Number(url.searchParams.get('n'));
|
|
490
|
+
const plan = S.readVersion(w.root, id, n);
|
|
491
|
+
return send(plan ? { status: 200, body: { ok: true, plan } } : bad(404, 'no such version'));
|
|
492
|
+
}
|
|
493
|
+
if (sub === 'check') {
|
|
494
|
+
const sid = text(url.searchParams.get('source'));
|
|
495
|
+
if (!S.SLUG_RE.test(sid)) return send(bad(400, 'source must be a source id'));
|
|
496
|
+
try { settleChecks(ctx, w, id); } catch { /* ignore */ }
|
|
497
|
+
const c = S.readCheck(w.root, id, sid);
|
|
498
|
+
return send(c ? { status: 200, body: { ok: true, check: c } } : bad(404, 'this source has not been checked'));
|
|
499
|
+
}
|
|
500
|
+
if (sub === 'dry') {
|
|
501
|
+
const run = text(url.searchParams.get('run'));
|
|
502
|
+
if (!S.RUN_RE.test(run)) return send(bad(400, 'run must be a run id'));
|
|
503
|
+
const d = S.readDry(w.root, id, run);
|
|
504
|
+
return send(d ? { status: 200, body: { ok: true, dry: d } } : bad(404, 'no such run'));
|
|
505
|
+
}
|
|
506
|
+
return send(bad(404, 'no such route'));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (isCharts || !POSTS.includes(sub)) return send(bad(404, 'no such route'));
|
|
510
|
+
if (req.method !== 'POST') return send(bad(405, 'POST only'));
|
|
511
|
+
const read = await ctx.readJson(req);
|
|
512
|
+
if (!read.ok) return send(bad(400, 'the body is not JSON'));
|
|
513
|
+
const body = read.body || {};
|
|
514
|
+
if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'an agent session cannot change a report structure, add a source, start a check or save a chart. Only a press on the page does'));
|
|
515
|
+
const g = guard.requirePagePress(req, body);
|
|
516
|
+
if (!g.ok) return send(bad(g.status, g.error));
|
|
517
|
+
const w = wsOf(ctx, text(body.ws));
|
|
518
|
+
if (!w) return send(bad(404, 'no such workspace'));
|
|
519
|
+
const root = w.root;
|
|
520
|
+
const fail = (r) => send(bad(r.status || 400, r.error));
|
|
521
|
+
const id = text(body.id);
|
|
522
|
+
if (!S.CADENCE_RE.test(id)) return send(bad(400, 'id must be a report id'));
|
|
523
|
+
const setup = setupOf(root, id);
|
|
524
|
+
if (!setup) return send(bad(404, 'no such report'));
|
|
525
|
+
const busy = () => jobs.has(keyOf(root, id));
|
|
526
|
+
const answer = () => send({ status: 200, body: { ok: true, structure: answerOf(root, id) } });
|
|
527
|
+
const BUSY = 'PMOS is already working on this report. Wait for the answer, then try again.';
|
|
528
|
+
const current = () => { const st = S.stateOf(root, id); return { st, plan: st.current != null ? S.readVersion(root, id, st.current) : null }; };
|
|
529
|
+
|
|
530
|
+
if (sub === 'turn') {
|
|
531
|
+
if (busy()) return send(bad(409, BUSY));
|
|
532
|
+
const t = text(body.text).trim();
|
|
533
|
+
if (!t || t.length > S.LIMITS.turn) return send(bad(400, `text is required, at most ${S.LIMITS.turn} characters`));
|
|
534
|
+
const st = S.stateOf(root, id);
|
|
535
|
+
S.append(root, id, { ev: 'turn', text: t, base: st.current }, { actor: g.actor });
|
|
536
|
+
const s = startJob(ctx, w, id, { kind: 'turn', turnText: t });
|
|
537
|
+
if (!s.ok) { S.append(root, id, { ev: 'error', text: s.error, mode: 'turn' }); return fail(s); }
|
|
538
|
+
return send({ status: 200, body: { ok: true, running: true } });
|
|
539
|
+
}
|
|
540
|
+
if (sub === 'dry') {
|
|
541
|
+
if (busy()) return send(bad(409, BUSY));
|
|
542
|
+
const st = S.stateOf(root, id);
|
|
543
|
+
const n = body.n === undefined ? st.current : body.n;
|
|
544
|
+
if (!Number.isInteger(n) || !st.versions.has(n)) return send(bad(404, 'no such version'));
|
|
545
|
+
const s = startJob(ctx, w, id, { kind: 'dry', n });
|
|
546
|
+
if (!s.ok) return fail(s);
|
|
547
|
+
return send({ status: 200, body: { ok: true, running: true } });
|
|
548
|
+
}
|
|
549
|
+
if (sub === 'revert') {
|
|
550
|
+
if (busy()) return send(bad(409, BUSY));
|
|
551
|
+
const n = body.n;
|
|
552
|
+
if (!Number.isInteger(n) || !S.stateOf(root, id).versions.has(n)) return send(bad(404, 'no such version'));
|
|
553
|
+
S.append(root, id, { ev: 'revert', n }, { actor: g.actor });
|
|
554
|
+
return answer();
|
|
555
|
+
}
|
|
556
|
+
if (sub === 'accept') {
|
|
557
|
+
if (busy()) return send(bad(409, BUSY));
|
|
558
|
+
const n = body.n;
|
|
559
|
+
const st = S.stateOf(root, id);
|
|
560
|
+
if (!Number.isInteger(n) || !st.versions.has(n)) return send(bad(404, 'no such version'));
|
|
561
|
+
const plan = S.readVersion(root, id, n);
|
|
562
|
+
if (!plan) return send(bad(404, 'no such version'));
|
|
563
|
+
const v = S.validatePlan(plan, { charts: storeIds(root) });
|
|
564
|
+
if (!v.ok) return send(bad(409, `S${n} cannot be saved: ${v.error}`));
|
|
565
|
+
// each New chart is written to the chart store once and the plan then points at it; a From Charts chart is never copied
|
|
566
|
+
const next = { ...v.plan, charts: v.plan.charts.map((c) => ({ ...c })) };
|
|
567
|
+
const written = [];
|
|
568
|
+
for (const c of next.charts) {
|
|
569
|
+
if (c.chart) continue;
|
|
570
|
+
const src = next.sources.find((s) => s.id === c.source) || {};
|
|
571
|
+
const r = CS.addChart(root, { id: c.id, name: c.name, question: c.question, source: { kind: src.kind || 'pmos-level', name: src.name || '', ...(src.via ? { via: src.via } : {}), ...(src.ref ? { ref: src.ref } : {}) },
|
|
572
|
+
from: c.from, how: c.how, compare: c.compare, flag: c.flag, query: c.query, why: c.why, created_by: `report-structure:${id}` }, { actor: g.actor });
|
|
573
|
+
if (!r.ok) return fail(r);
|
|
574
|
+
c.chart = r.chart.id; written.push(r.chart.id);
|
|
575
|
+
}
|
|
576
|
+
if (written.length) S.rewriteVersion(root, id, n, next);
|
|
577
|
+
S.append(root, id, { ev: 'accept', n, charts: written }, { actor: g.actor });
|
|
578
|
+
const b = S.writeBrief(root, id, setup);
|
|
579
|
+
if (!b.ok) return fail(b);
|
|
580
|
+
return send({ status: 200, body: { ok: true, structure: answerOf(root, id), brief: b.path, charts: written } });
|
|
581
|
+
}
|
|
582
|
+
if (sub === 'source') {
|
|
583
|
+
if (busy()) return send(bad(409, BUSY));
|
|
584
|
+
const { st, plan } = current();
|
|
585
|
+
if (!plan) return send(bad(409, 'there is no structure yet. Tell PMOS what the report is for first'));
|
|
586
|
+
const v = S.validateSource(body.source, { taken: plan.sources.map((s) => s.id) });
|
|
587
|
+
if (!v.ok) return fail(v);
|
|
588
|
+
const added = S.addSourceTo(plan, v.source);
|
|
589
|
+
if (!added.ok) return fail(added);
|
|
590
|
+
const land = S.landVersion(root, id, added.plan, { turn: `Added the source ${v.source.name}`, answer: `Made S${S.nextN(st)} with ${v.source.name} added as a source.`, from: 'source',
|
|
591
|
+
before: [{ ev: 'source', source: v.source }], actor: g.actor, charts: storeIds(root) });
|
|
592
|
+
if (!land.ok) return fail({ status: 400, error: land.error });
|
|
593
|
+
if (v.source.kind === 'chrome' || v.source.kind === 'computer') startCheck(ctx, w, id, v.source.id);
|
|
594
|
+
return answer();
|
|
595
|
+
}
|
|
596
|
+
if (sub === 'remove') {
|
|
597
|
+
if (busy()) return send(bad(409, BUSY));
|
|
598
|
+
const { st, plan } = current();
|
|
599
|
+
if (!plan) return send(bad(409, 'there is no structure yet'));
|
|
600
|
+
const ref = text(body.ref);
|
|
601
|
+
if (!S.SLUG_RE.test(ref)) return send(bad(400, 'ref must be an id'));
|
|
602
|
+
const r = S.removeFrom(plan, text(body.kind), ref);
|
|
603
|
+
if (!r.ok) return fail(r);
|
|
604
|
+
const land = S.landVersion(root, id, r.plan, { turn: `Removed the ${body.kind} ${ref}`, answer: `Made S${S.nextN(st)} without the ${body.kind} ${ref}.`, from: 'remove',
|
|
605
|
+
before: [{ ev: 'remove', kind: body.kind, ref }], actor: g.actor, charts: storeIds(root) });
|
|
606
|
+
if (!land.ok) return fail({ status: 400, error: land.error });
|
|
607
|
+
return answer();
|
|
608
|
+
}
|
|
609
|
+
if (sub === 'check') {
|
|
610
|
+
const sid = text(body.source);
|
|
611
|
+
if (!S.SLUG_RE.test(sid)) return send(bad(400, 'source must be a source id'));
|
|
612
|
+
const r = startCheck(ctx, w, id, sid);
|
|
613
|
+
return r.ok ? send({ status: 200, body: { ok: true, check: r.check } }) : fail(r);
|
|
614
|
+
}
|
|
615
|
+
return send(bad(404, 'no such route'));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
module.exports = { prefixes: ['/api/report-structure', '/api/charts'], handle, sweep, beforeDraft, draftBlock, startCheck, settleChecks, readHold, savedPlan, computerRule, resultJson, promptFor,
|
|
619
|
+
_jobs: jobs, _checks: checkJobs };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.242.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.242.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED