create-pmos 0.210.0 → 0.212.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/c3-light-load-test.js +110 -0
  7. package/workspace/c3-settings-test.js +53 -2
  8. package/workspace/guide/briefing.md +7 -1
  9. package/workspace/guide/data-freshness.md +35 -8
  10. package/workspace/guide/index.md +1 -1
  11. package/workspace/lib/driver-run.js +4 -3
  12. package/workspace/lib/freshness.js +2 -0
  13. package/workspace/lib/refresh-run.js +300 -0
  14. package/workspace/lib/refresh.js +464 -0
  15. package/workspace/lib/report-finder.js +22 -1
  16. package/workspace/lib/route-modules.js +1 -0
  17. package/workspace/lib/routes-calendar-views.js +10 -2
  18. package/workspace/lib/routes-pm-items.js +2 -0
  19. package/workspace/lib/routes-refresh.js +93 -0
  20. package/workspace/lib/settings-store.js +0 -2
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-brief-pulse.js +4 -4
  24. package/workspace/public/pm/c3-calpop.js +11 -3
  25. package/workspace/public/pm/c3-dock.js +9 -0
  26. package/workspace/public/pm/c3-driver.js +1 -1
  27. package/workspace/public/pm/c3-look.js +27 -0
  28. package/workspace/public/pm/c3-plan.js +3 -2
  29. package/workspace/public/pm/c3-quiet.css +6 -1
  30. package/workspace/public/pm/c3-report-store.js +2 -1
  31. package/workspace/public/pm/c3-scan-setup.js +3 -7
  32. package/workspace/public/pm/c3-scan-words.js +7 -0
  33. package/workspace/public/pm/c3-scan.js +6 -4
  34. package/workspace/public/pm/c3-settings.css +11 -0
  35. package/workspace/public/pm/c3-settings.js +236 -27
  36. package/workspace/public/pm/c3-shell.js +56 -100
  37. package/workspace/replaced-tests.json +11 -0
  38. package/workspace/server.js +106 -12
  39. package/workspace/test-lists/c3-desktop-light-load.json +6 -0
  40. package/workspace/test-lists/data-refresh-settings.json +5 -0
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+ /* refresh-run.js — the refresh engine bound to a running server (initiative data-refresh-settings, PRD R3 to R8, R10).
3
+ *
4
+ * const run = create(host)
5
+ * run.tick(now) each minute: start the skill sources that are due, write skip lines, end lost runs, prune once a day
6
+ * run.started(session) · run.ended(session) every background session passes here; one with a refresh origin is logged (start, end)
7
+ * run.briefDone(got) a morning brief arrived: its run is done (the brief session stays open after it submits)
8
+ * run.codeRun(id, { result, got, error, took_ms, trigger }) one line pair for a run with no session (signals, repository, Git sync)
9
+ * run.jobGate(id, now) may job `id` run its tick now (off, or an input still running)
10
+ * run.runNow(id, { ws, withInputs }) Refresh now; with inputs, the old ones first and this one once they end
11
+ * run.rows(ws) · run.setSource(id, patch) · run.setRules(patch) · run.ensureOn(id, ws) · run.settings() · run.log()
12
+ *
13
+ * lib/refresh.js decides; this file holds the state between minutes and calls the host. The host is server.js:
14
+ * { stateDir, now?, homeWs(), proactive() → { quiet, dayCheck: { start, end, every } }, schedules: { read, write },
15
+ * startSkill(wsId, skill, name, origin) → { ok, session?, error? }, jobs: { [id]: (ws) → { ok, error? } },
16
+ * freshRows(ws) → [freshness row], copyPaths(id, ws) → [path], tell({ source, label, error, ws }), sessionAlive(id) → bool,
17
+ * setDayCheckEvery(every), setRepoWatch(on), warn(what, e) }
18
+ * Nothing here calls out of this machine: a skill source's run is the PM's own agent session, with the PM's connections.
19
+ */
20
+ const R = require('./refresh.js');
21
+
22
+ const PRUNE_EVERY_MS = 24 * 3600 * 1000;
23
+ const LOST_MS = 6 * 3600 * 1000; // a run with no live session and no end after this long is written as interrupted
24
+
25
+ function create(host) {
26
+ const now = () => (host.now ? host.now() : Date.now());
27
+ const dir = host.stateDir;
28
+ const warn = (what, e) => { try { if (host.warn) host.warn(what, e); } catch { /* nothing to tell */ } };
29
+ const expect = new Map(); // source id → { trigger, note, inputs } for the start the engine itself is making now
30
+ const jobWait = new Map(); // job id → ms it started waiting for an input
31
+ let lastPrune = 0;
32
+
33
+ /* ── settings, with the one-time move of the seed schedules (R3) ── */
34
+ function settings() {
35
+ let s = R.readSettings(dir);
36
+ if (!s.migrated) {
37
+ try {
38
+ const m = R.migrate(s, host.schedules.read(), { homeWs: host.homeWs() });
39
+ if (m.adopted.length) host.schedules.write(m.schedules);
40
+ R.writeSettings(dir, m.settings);
41
+ s = R.withDefaults(m.settings);
42
+ } catch (e) { warn('refresh settings (moving the schedules)', e); }
43
+ }
44
+ // The day check's minutes have one home: the proactive settings.
45
+ const every = String(((host.proactive() || {}).dayCheck || {}).every || '60');
46
+ s.sources.daycheck.s = { kind: 'every', every: ['30', '60'].includes(every) ? every : '60', work: true };
47
+ return s;
48
+ }
49
+ const win = () => { const d = (host.proactive() || {}).dayCheck || {}; return { start: d.start || '08:00', end: d.end || '18:00' }; };
50
+ const quiet = () => { const q = (host.proactive() || {}).quiet; return q && q.start && q.end ? q : null; };
51
+ const log = () => R.foldLog(R.readLog(dir));
52
+
53
+ /* ── the state of each source between minutes ── */
54
+ const openOf = (st) => (st && st.open && typeof st.open === 'object' ? st.open : {});
55
+ const running = (st) => Object.keys(openOf(st)).length > 0;
56
+ function inputStateFn(set, state, t) {
57
+ return (id) => {
58
+ const x = set.sources[id]; const st = state[id] || {};
59
+ if (!x || !x.on) return 'off';
60
+ if (running(st)) return 'running';
61
+ if (st.waiting_since || st.pending) return 'waiting';
62
+ const src = R.BY_ID.get(id);
63
+ if (src && src.run === 'skill') {
64
+ const slot = R.lastSlot(x.s, t, win());
65
+ if (slot != null && (st.last_slot == null || slot > st.last_slot) && t - slot < 2 * 3600 * 1000) return 'due';
66
+ }
67
+ return 'ok';
68
+ };
69
+ }
70
+ const runningCount = (state) => Object.values(state).reduce((n, st) => n + Object.keys(openOf(st)).length, 0);
71
+ function snapshot(id, set, state, t) {
72
+ const src = R.BY_ID.get(id); if (!src || !src.uses.length) return [];
73
+ const f = log(); const is = inputStateFn(set, state, t);
74
+ return src.uses.map((u) => { const l = f.lastRun(u); return { id: u, state: is(u), at: l ? (l.end_at || l.start_at) : null, result: l ? l.result : null }; });
75
+ }
76
+
77
+ /* ── a session started or ended ── */
78
+ const skillOfSchedule = (id) => { try { const x = host.schedules.read().find((y) => y.id === id); return x ? x.skill : null; } catch { return null; } };
79
+ function started(s) {
80
+ if (!s) return;
81
+ const id = R.sourceOfOrigin(s.origin, { skillOfSchedule });
82
+ if (!id) return;
83
+ const t = now();
84
+ const set = settings(); const state = R.readState(dir);
85
+ const ex = expect.get(id) || {}; expect.delete(id);
86
+ const st = state[id] || {};
87
+ const run = R.newRun(id, t);
88
+ const trigger = ex.trigger || (String(s.origin).startsWith('schedule:') ? 'schedule' : String(s.origin).startsWith('refresh:') ? 'pressed' : 'schedule');
89
+ const inputs = snapshot(id, set, state, t);
90
+ state[id] = { ...st, open: { ...openOf(st), [s.id]: { run, at: t, trigger } }, started_at: t, trigger, waiting_since: null, waiting_for: [], pending: null };
91
+ if (ex.slot != null) state[id].last_slot = ex.slot;
92
+ R.writeState(dir, state);
93
+ R.append(dir, { source: id, ws: s.ws || null, ev: 'start', run, trigger, session: s.id, inputs, ...(ex.note ? { note: ex.note } : {}) }, t);
94
+ if (s.state === 'exited') ended(s);
95
+ }
96
+ /** End the open run of session `s`. `over` sets the result for a run whose session has no outcome of its own. */
97
+ function ended(s, over) {
98
+ if (!s) return;
99
+ const state = R.readState(dir);
100
+ const id = Object.keys(state).find((k) => openOf(state[k])[s.id]);
101
+ if (!id) return;
102
+ const t = now();
103
+ const st = state[id]; const o = openOf(st)[s.id];
104
+ let result; let got = null; let error = null;
105
+ if (over) { result = over.result; got = over.got || null; error = over.error || null; }
106
+ else if (s.outcome) { result = s.outcome.ok ? 'done' : /Stopped by you/.test(s.outcome.why || '') ? 'interrupted' : 'failed'; got = s.outcome.headline || null; error = s.outcome.ok ? null : s.outcome.why; }
107
+ else if (id === 'brief') { result = 'failed'; error = 'The session ended without submitting a brief' + (s.note ? ` (${s.note})` : '') + '.'; }
108
+ else { result = /code 0\)$/.test(s.note || '') ? 'done' : 'failed'; error = result === 'failed' ? s.note || 'the session ended' : null; }
109
+ const rest = { ...openOf(st) }; delete rest[s.id];
110
+ const rules = settings().rules;
111
+ let copy = null;
112
+ const src = R.BY_ID.get(id);
113
+ if (result === 'done' && src && src.copy) { try { copy = R.keepCopy(dir, id, host.copyPaths(id, s.ws), t); } catch (e) { warn('refresh copy', e); } }
114
+ const next = { ...st, open: rest, last_end: t, last_result: result };
115
+ if (result === 'done') { next.retry_at = null; next.retried = false; }
116
+ const scheduled = ['schedule', 'catch-up', 'retry'].includes(o.trigger);
117
+ if (result === 'failed' && scheduled && src && src.run === 'skill' && rules.retry !== 'no' && !st.retried) { next.retry_at = t + Number(rules.retry) * 60000; next.retried = true; }
118
+ else if (result === 'failed' && o.trigger === 'retry') next.retry_at = null;
119
+ state[id] = next;
120
+ R.writeState(dir, state);
121
+ R.append(dir, { source: id, ws: s.ws || null, ev: 'end', run: o.run, result, took_ms: t - o.at, got, error, ...(copy ? { copy } : {}) }, t);
122
+ if (result === 'failed' && scheduled && rules.tell === 'brief' && !next.retry_at) {
123
+ try { host.tell({ source: id, label: src ? src.label : id, error: error || 'it failed', ws: s.ws || null }); } catch (e) { warn('refresh failure note', e); }
124
+ }
125
+ }
126
+ /** The morning brief arrived: the brief run that is open is done. */
127
+ function briefDone(got) {
128
+ const state = R.readState(dir); const st = state.brief;
129
+ for (const sid of Object.keys(openOf(st))) ended({ id: sid, ws: null }, { result: 'done', got: got || 'Briefing saved' });
130
+ }
131
+ /** A run with no session: one start line and one end line (signals, the repository rebuild, Git sync). */
132
+ function codeRun(id, { result = 'done', got = null, error = null, took_ms = null, trigger = 'change', ws = null } = {}) {
133
+ if (!R.BY_ID.has(id)) return;
134
+ const t = now(); const run = R.newRun(id, t);
135
+ const ex = expect.get(id); if (ex) { trigger = ex.trigger || trigger; expect.delete(id); }
136
+ R.append(dir, { source: id, ws, ev: 'start', run, trigger }, took_ms != null ? t - took_ms : t);
137
+ R.append(dir, { source: id, ws, ev: 'end', run, result, took_ms, got, error }, t);
138
+ }
139
+
140
+ /* ── the tick ── */
141
+ function tick(t = now()) {
142
+ const set = settings(); const state = R.readState(dir); const rules = set.rules;
143
+ let changed = false;
144
+ // A run whose session is gone with no end (the server restarted under it) is written as interrupted.
145
+ for (const [id, st] of Object.entries(state)) {
146
+ for (const [sid, o] of Object.entries(openOf(st))) {
147
+ let alive = true; try { alive = host.sessionAlive(sid); } catch { alive = true; }
148
+ if (!alive || t - o.at > LOST_MS) {
149
+ delete st.open[sid]; st.retry_at = null; changed = true;
150
+ R.append(dir, { source: id, ev: 'end', run: o.run, result: 'interrupted', took_ms: t - o.at, error: alive ? 'no end after 6 hours' : 'the session was gone (PMOS restarted, or the session was closed)' }, t);
151
+ }
152
+ }
153
+ }
154
+ if (changed) R.writeState(dir, state);
155
+ const first = !tick.seen; tick.seen = true;
156
+ for (const src of R.SOURCES.filter((x) => x.run === 'skill')) {
157
+ const cur = R.readState(dir);
158
+ const st = { ...(cur[src.id] || {}), running: running(cur[src.id]) };
159
+ const d = R.decide(src, st, { now: t, settings: set, rules, win: win(), quiet: quiet(), runningCount: runningCount(cur), inputState: inputStateFn(set, cur, t), first });
160
+ if (d.act === 'none') continue;
161
+ const ws = (set.sources[src.id] && set.sources[src.id].ws) || (st.pending && st.pending.ws) || host.homeWs();
162
+ if (d.act === 'skip') {
163
+ cur[src.id] = { ...(cur[src.id] || {}), last_slot: d.slot };
164
+ R.writeState(dir, cur);
165
+ if (!d.quiet) R.append(dir, { source: src.id, ws, ev: 'skip', trigger: 'schedule', note: d.why }, t);
166
+ continue;
167
+ }
168
+ if (d.act === 'wait') {
169
+ const x = cur[src.id] || {};
170
+ if (!x.waiting_since || JSON.stringify(x.waiting_for || []) !== JSON.stringify(d.waiting || [])) {
171
+ cur[src.id] = { ...x, waiting_since: x.waiting_since || t, waiting_for: d.waiting || [], waiting_why: d.why || null };
172
+ R.writeState(dir, cur);
173
+ }
174
+ continue;
175
+ }
176
+ start(src, ws, { trigger: d.trigger, slot: d.slot, note: d.note });
177
+ }
178
+ if (t - lastPrune > PRUNE_EVERY_MS) {
179
+ lastPrune = t;
180
+ try { const p = R.prune(dir, rules.keep, t); if (p.lines || p.copies) warn('refresh log', { message: `removed ${p.lines} lines and ${p.copies} copies older than ${rules.keep} days` }); } catch (e) { warn('refresh log prune', e); }
181
+ }
182
+ }
183
+ function start(src, ws, { trigger, slot, note }) {
184
+ expect.set(src.id, { trigger, slot, note });
185
+ let r;
186
+ try { r = host.startSkill(ws, src.skill, `${src.label} · refresh`, 'refresh:' + src.id); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
187
+ if (expect.has(src.id)) {
188
+ // Nothing started (no session was made): write it as a failed run so it shows, and keep the slot so it is not tried every minute.
189
+ expect.delete(src.id);
190
+ const t = now(); const run = R.newRun(src.id, t); const cur = R.readState(dir);
191
+ cur[src.id] = { ...(cur[src.id] || {}), last_slot: slot != null ? slot : (cur[src.id] || {}).last_slot, waiting_since: null, waiting_for: [], pending: null, last_result: 'failed' };
192
+ R.writeState(dir, cur);
193
+ R.append(dir, { source: src.id, ws, ev: 'start', run, trigger }, t);
194
+ R.append(dir, { source: src.id, ws, ev: 'end', run, result: 'failed', took_ms: 0, error: (r && r.error) || 'could not start' }, t);
195
+ }
196
+ return r || { ok: false, error: 'could not start' };
197
+ }
198
+
199
+ /* ── job gates ── */
200
+ function jobGate(id, t = now()) {
201
+ const set = settings(); const state = R.readState(dir);
202
+ const g = R.jobGate(id, { now: t, settings: set, inputState: inputStateFn(set, state, t), waitingSince: jobWait.get(id) || null });
203
+ if (g.ok) { const w0 = jobWait.get(id); jobWait.delete(id); if (w0 && t - w0 >= R.WAIT_MS) expect.set(id, { trigger: 'schedule', note: 'Waited 30 min for its inputs, then ran without them' }); }
204
+ else if (g.why === 'waiting' && !jobWait.has(id)) jobWait.set(id, t);
205
+ return g;
206
+ }
207
+
208
+ /* ── presses ── */
209
+ function runNow(id, { ws, withInputs } = {}) {
210
+ const src = R.BY_ID.get(id);
211
+ if (!src) return { ok: false, status: 400, error: 'source must be one of ' + R.SOURCES.map((x) => x.id).join(', ') };
212
+ const set = settings(); const t = now();
213
+ const startedInputs = [];
214
+ if (withInputs) {
215
+ const rows = rowsFor(ws, set, t);
216
+ const me = rows.find((r) => r.id === id);
217
+ for (const inp of me ? me.built_on : []) {
218
+ if (inp.state === 'off') continue;
219
+ const r = runNow(inp.id, { ws });
220
+ if (r.ok) startedInputs.push(inp.id);
221
+ }
222
+ }
223
+ if (src.run === 'skill') {
224
+ const state = R.readState(dir);
225
+ if (running(state[id])) return { ok: false, status: 409, error: `${src.label} is already refreshing` };
226
+ const busy = src.uses.filter((u) => ['running', 'waiting', 'due'].includes(inputStateFn(set, state, t)(u)));
227
+ if (busy.length) {
228
+ state[id] = { ...(state[id] || {}), pending: { trigger: 'pressed', at: t, ws: ws || null }, waiting_since: t, waiting_for: busy };
229
+ R.writeState(dir, state);
230
+ return { ok: true, source: id, waiting: busy, inputs: startedInputs };
231
+ }
232
+ const r = start(src, ws || (set.sources[id] && set.sources[id].ws) || host.homeWs(), { trigger: 'pressed' });
233
+ return r.ok ? { ok: true, source: id, started: 'session', session: r.session, inputs: startedInputs } : { ok: false, status: r.status || 500, error: r.error };
234
+ }
235
+ const job = host.jobs && host.jobs[id];
236
+ if (!job) return { ok: false, status: 409, error: `${src.label} cannot be started from here` };
237
+ expect.set(id, { trigger: 'pressed' });
238
+ let r; try { r = job(ws); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
239
+ if (!r || !r.ok) { expect.delete(id); return { ok: false, status: (r && r.status) || 409, error: (r && r.error) || 'it could not start' }; }
240
+ return { ok: true, source: id, started: r.started || 'job', ...(r.session ? { session: r.session } : {}), inputs: startedInputs };
241
+ }
242
+ function setSource(id, patch) {
243
+ const src = R.BY_ID.get(id);
244
+ if (!src) return { ok: false, error: 'source must be one of ' + R.SOURCES.map((x) => x.id).join(', ') };
245
+ if (patch.on !== undefined && typeof patch.on !== 'boolean') return { ok: false, error: 'on must be true or false' };
246
+ let norm = null;
247
+ if (patch.schedule !== undefined) { norm = R.normalize(src, patch.schedule); if (!norm.ok) return norm; }
248
+ if (patch.on === undefined && !norm) return { ok: false, error: 'give on, schedule or both' };
249
+ const set = R.readSettings(dir);
250
+ const cur = set.sources[id];
251
+ if (typeof patch.on === 'boolean') cur.on = patch.on;
252
+ if (norm && id === 'daycheck') { try { host.setDayCheckEvery(norm.s.every); } catch (e) { return { ok: false, error: 'the day check could not be saved' }; } }
253
+ else if (norm) cur.s = norm.s;
254
+ if (id === 'repo' && typeof patch.on === 'boolean') { try { host.setRepoWatch(patch.on); } catch (e) { warn('repository watch', e); } }
255
+ R.writeSettings(dir, set);
256
+ // A schedule change starts the clock from now: a slot that passed before the change is not run late.
257
+ if (norm && id !== 'daycheck') { const st = R.readState(dir); const sl = R.lastSlot(norm.s, now(), win()); st[id] = { ...(st[id] || {}), last_slot: sl, retry_at: null }; R.writeState(dir, st); }
258
+ if (patch.on === true && src.run === 'skill') { const st = R.readState(dir); const sl = R.lastSlot(cur.s, now(), win()); st[id] = { ...(st[id] || {}), last_slot: sl }; R.writeState(dir, st); }
259
+ return { ok: true, source: id };
260
+ }
261
+ function setRules(patch) {
262
+ const n = R.normalizeRules(patch); if (!n.ok) return n;
263
+ const set = R.readSettings(dir); set.rules = { ...set.rules, ...n.patch }; R.writeSettings(dir, set);
264
+ return { ok: true, rules: set.rules };
265
+ }
266
+ /** A seed set or Prepare my day asked for a managed skill: turn its source on instead of adding a schedule (R3). */
267
+ function ensureOn(id, ws) {
268
+ const set = R.readSettings(dir); const cur = set.sources[id]; if (!cur) return false;
269
+ const was = cur.on; cur.on = true; if (ws && !cur.ws) cur.ws = ws;
270
+ set.migrated = true;
271
+ R.writeSettings(dir, set);
272
+ if (!was) { const st = R.readState(dir); st[id] = { ...(st[id] || {}), last_slot: R.lastSlot(cur.s, now(), win()) }; R.writeState(dir, st); }
273
+ return !was;
274
+ }
275
+
276
+ /* ── rows ── */
277
+ const FRESH_OF = { 'my-work': 'my-work', calendar: 'calendar', jira: 'jira', tracker: 'tracker', initiatives: 'initiatives' };
278
+ function rowsFor(ws, set, t) {
279
+ const state = R.readState(dir);
280
+ const view = {};
281
+ for (const [k, v] of Object.entries(state)) view[k] = { ...v, running: running(v) };
282
+ let fr = []; try { fr = host.freshRows(ws) || []; } catch { fr = []; }
283
+ const fresh = new Map(fr.filter((x) => FRESH_OF[x.id]).map((x) => [x.id, x]));
284
+ return R.rowsOf({ now: t, settings: set, state: view, log: log(), fresh, win: win(), quiet: quiet(), jobSchedule: host.jobSchedule || null, ws });
285
+ }
286
+ function rows(ws) { const set = settings(); return { rules: set.rules, quiet: quiet(), window: win(), rows: rowsFor(ws, set, now()) }; }
287
+ /** What lib/freshness.js shows as a skill's schedule, now that the schedule lives here. */
288
+ function scheduleOfSkill(skill) {
289
+ const id = R.SKILL_OF.get(skill); if (!id) return null;
290
+ const set = settings(); const x = set.sources[id]; const st = R.readState(dir)[id] || {};
291
+ const l = log().lastRun(id);
292
+ const next = x.on ? R.nextSlot(x.s, now(), win()) : null;
293
+ return { cadence: x.on ? R.textOf(x.s) : 'off', time: null, day: null, next_run: next ? new Date(next).toISOString() : null,
294
+ last_run: l ? l.start_at : null, last_status: l ? (l.result === 'failed' ? 'fail' : l.result === 'done' ? 'ok' : 'started') : st.last_result || null };
295
+ }
296
+
297
+ return { tick, started, ended, briefDone, codeRun, jobGate, runNow, setSource, setRules, ensureOn, rows, settings, log, scheduleOfSkill, managedSkill: (skill) => R.SKILL_OF.get(skill) || null };
298
+ }
299
+
300
+ module.exports = { create };