create-pmos 0.130.0 → 0.132.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-one-home-test.js +4 -2
- package/workspace/c3-work-test.js +200 -0
- package/workspace/fixtures/one-home-baseline.json +0 -91
- package/workspace/guide/console-calendar.md +65 -0
- package/workspace/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/my-work.md +76 -1
- package/workspace/lib/calendar-cli.js +84 -0
- package/workspace/lib/calendar-layers.js +372 -0
- package/workspace/lib/facts-async.js +144 -35
- package/workspace/lib/freshness.js +167 -0
- package/workspace/lib/offloop.js +94 -0
- package/workspace/lib/pm-items.js +71 -6
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-calendar-views.js +112 -3
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-pm-items.js +123 -13
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/lib/work-focus.js +106 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +392 -0
- package/workspace/public/pm/c3-demo-cal4.js +114 -0
- package/workspace/public/pm/c3-demo-data.js +79 -77
- package/workspace/public/pm/c3-demo-misc2.js +4 -16
- package/workspace/public/pm/c3-demo-work-ix.js +19 -67
- package/workspace/public/pm/c3-demo.js +10 -3
- package/workspace/public/pm/c3-shell.js +5 -2
- package/workspace/public/pm/calendar-views.js +10 -3
- package/workspace/public/pm/components.css +34 -0
- package/workspace/public/pm/console3.css +155 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/public/pm/pm-work-view.js +364 -172
- package/workspace/public/pm/work-place.js +58 -0
- package/workspace/server.js +39 -2
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/calendar-layers.json +5 -0
- package/workspace/test-lists/calendar-time-page.json +5 -0
- package/workspace/test-lists/console3-work-page.json +9 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -0
- package/workspace/public/pm/c3-demo-work2.js +0 -13
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* freshness.js — one change detector per workspace, a data version the pages watch, and how old each source is
|
|
3
|
+
* (workspace-data-freshness R4, R6, R7).
|
|
4
|
+
*
|
|
5
|
+
* create({ stateDir, workspaces, onChange, schedules, factsStatus, now, pollMs, stableMs, watcher }) → {
|
|
6
|
+
* start(), stop(), tick(), the watcher (lib/repo-watch.js), one per server
|
|
7
|
+
* versions() → { <ws id>: n } the number moves when that workspace's files change
|
|
8
|
+
* sources(w) → [row] one row per source, oldest information first
|
|
9
|
+
* enabled(root) · setEnabled(root, on) the PM's off switch, per repository (default on)
|
|
10
|
+
* }
|
|
11
|
+
* SOURCES the source table: id, label, skill, stale_after_ms
|
|
12
|
+
*
|
|
13
|
+
* THE DETECTOR. lib/repo-watch.js polls the repository signature (HEAD, porcelain status, dirty file times) every pollMs
|
|
14
|
+
* and runs `check` once the signature has held still for stableMs. Here `check` bumps the workspace's data version and
|
|
15
|
+
* calls onChange(w), which the server uses to start the board and facts rebuilds. Async git, read-only verbs, no network.
|
|
16
|
+
* A first look sets the data version but calls no onChange: a server that starts does not read every repository at once,
|
|
17
|
+
* and each cache is read on its first request (the facts from their disk entry when the repository has not changed).
|
|
18
|
+
*
|
|
19
|
+
* A ROW. { id, label, as_of: ISO|null, state: 'fresh'|'stale'|'never'|'building'|'failed', detail, stale_after_ms,
|
|
20
|
+
* skill: id|null, schedule: { cadence, time, day?, next_run }|null }. A source with no reading is `never`: it is never
|
|
21
|
+
* shown as fresh because nothing is known about it.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here calls a tracker, a calendar or a network. The external sources are read by agent sessions with the PM's
|
|
24
|
+
* own connections; this module only reads the files those sessions leave behind.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const repoWatch = require('./repo-watch.js');
|
|
29
|
+
|
|
30
|
+
const H = 3600 * 1000;
|
|
31
|
+
const SOURCES = Object.freeze([
|
|
32
|
+
{ id: 'repository', label: 'Repository', skill: null, stale_after_ms: null },
|
|
33
|
+
{ id: 'initiatives', label: 'Initiatives', skill: null, stale_after_ms: null },
|
|
34
|
+
{ id: 'calendar', label: 'Calendar', skill: 'calendar-sync', stale_after_ms: 8 * H },
|
|
35
|
+
{ id: 'jira', label: 'Jira status', skill: 'jira-sync', stale_after_ms: 26 * H },
|
|
36
|
+
{ id: 'my-work', label: 'My work', skill: 'my-work-read', stale_after_ms: 26 * H },
|
|
37
|
+
{ id: 'tracker', label: 'Tracker items', skill: 'tracker-refresh', stale_after_ms: 8 * 24 * H },
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
|
|
41
|
+
const mtime = (f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } };
|
|
42
|
+
const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
|
|
43
|
+
|
|
44
|
+
/** The newest `fetched_at` among the imported calendar events (scripts/calendar-import.js ledger), in ms, or 0. */
|
|
45
|
+
function calendarAsOf(stateDir) {
|
|
46
|
+
let text = '';
|
|
47
|
+
try { text = fs.readFileSync(path.join(stateDir, 'calendar-import', 'events.jsonl'), 'utf8'); } catch { return 0; }
|
|
48
|
+
let best = 0;
|
|
49
|
+
for (const line of text.split('\n')) {
|
|
50
|
+
if (!line.includes('fetched_at')) continue;
|
|
51
|
+
try {
|
|
52
|
+
const o = JSON.parse(line);
|
|
53
|
+
const t = Date.parse((o.event && o.event.fetched_at) || o.fetched_at || '');
|
|
54
|
+
if (t > best) best = t;
|
|
55
|
+
} catch { /* a torn line is skipped */ }
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The newest Jira status snapshot of a repository (lib/jira-status.js writes <state>/tracker/jira/<initiative>.md). */
|
|
61
|
+
function jiraAsOf(repoStateDir) {
|
|
62
|
+
if (!repoStateDir) return 0;
|
|
63
|
+
const dir = path.join(repoStateDir, 'tracker', 'jira');
|
|
64
|
+
let names = [];
|
|
65
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return 0; }
|
|
66
|
+
return names.reduce((m, n) => Math.max(m, mtime(path.join(dir, n))), 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function create(o) {
|
|
70
|
+
const now = o.now || Date.now;
|
|
71
|
+
const settingsFile = o.stateDir ? path.join(o.stateDir, 'freshness.json') : null;
|
|
72
|
+
const versions = new Map(); // ws id -> n
|
|
73
|
+
const seen = new Map(); // root -> { checked_at, changed_at }
|
|
74
|
+
let settings = settingsFile ? readJson(settingsFile, { repos: {} }) : { repos: {} };
|
|
75
|
+
if (!settings || typeof settings.repos !== 'object') settings = { repos: {} };
|
|
76
|
+
|
|
77
|
+
const enabled = (root) => !(settings.repos[root] && settings.repos[root].watch === false);
|
|
78
|
+
function setEnabled(root, on) {
|
|
79
|
+
settings = { repos: { ...settings.repos, [root]: { watch: !!on } } };
|
|
80
|
+
if (settingsFile) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
|
|
83
|
+
const tmp = `${settingsFile}.${process.pid}.tmp`;
|
|
84
|
+
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
|
|
85
|
+
fs.renameSync(tmp, settingsFile);
|
|
86
|
+
} catch { return false; }
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const watcher = (o.watcher || repoWatch.createWatcher)({
|
|
92
|
+
repos: () => o.workspaces().map((w) => ({ id: w.id, root: w.root })),
|
|
93
|
+
enabled,
|
|
94
|
+
// a folder that is not a checkout: the times and sizes of its top-level files
|
|
95
|
+
listFiles: async (root) => ({ files: (() => { try { return fs.readdirSync(root).filter((n) => !n.startsWith('.')).slice(0, 2000); } catch { return []; } })() }),
|
|
96
|
+
check: async ({ root }) => {
|
|
97
|
+
const t = now();
|
|
98
|
+
const prev = seen.get(root) || {};
|
|
99
|
+
seen.set(root, { checked_at: t, changed_at: t, first: !prev.checked_at });
|
|
100
|
+
for (const w of o.workspaces()) {
|
|
101
|
+
if (w.root !== root) continue;
|
|
102
|
+
versions.set(w.id, (versions.get(w.id) || 0) + 1);
|
|
103
|
+
if (!prev.checked_at) continue; // a first look only records the signature: the caches warm on their first read
|
|
104
|
+
try { if (o.onChange) o.onChange(w); } catch { /* a cache that cannot be refreshed is refreshed by its own rule */ }
|
|
105
|
+
}
|
|
106
|
+
return { ok: true };
|
|
107
|
+
},
|
|
108
|
+
pollMs: o.pollMs || 10000,
|
|
109
|
+
stableMs: o.stableMs == null ? 3000 : o.stableMs,
|
|
110
|
+
sweepEveryMs: 24 * H,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function scheduleOf(skill) {
|
|
114
|
+
if (!skill || !o.schedules) return null;
|
|
115
|
+
let list = [];
|
|
116
|
+
try { list = o.schedules() || []; } catch { return null; }
|
|
117
|
+
const s = list.find((x) => x && x.skill === skill && x.enabled !== false);
|
|
118
|
+
return s ? { cadence: s.cadence || null, time: s.time || null, day: s.day || null, next_run: s.nextRun || null, last_run: s.lastRun || null, last_status: s.lastStatus || null } : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function row(src, asOfMs, extra = {}) {
|
|
122
|
+
const t = now();
|
|
123
|
+
let state = asOfMs ? 'fresh' : 'never';
|
|
124
|
+
if (asOfMs && src.stale_after_ms && t - asOfMs > src.stale_after_ms) state = 'stale';
|
|
125
|
+
const schedule = scheduleOf(src.skill);
|
|
126
|
+
if (schedule && schedule.last_status === 'fail' && state !== 'never') state = 'failed';
|
|
127
|
+
return { id: src.id, label: src.label, as_of: iso(asOfMs), state, detail: null, stale_after_ms: src.stale_after_ms, skill: src.skill, schedule, ...extra };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One row per source for workspace `w` ({ id, root, repoStateDir }). Reads memory, small files and file times only. */
|
|
131
|
+
function sources(w) {
|
|
132
|
+
const S = Object.fromEntries(SOURCES.map((s) => [s.id, s]));
|
|
133
|
+
const out = [];
|
|
134
|
+
const sn = seen.get(w.root);
|
|
135
|
+
const watching = enabled(w.root);
|
|
136
|
+
// a watched repository is checked every few seconds, so it is as fresh as now; `changed_at` is the last change seen
|
|
137
|
+
out.push(row(S.repository, sn ? (watching ? now() : sn.checked_at) : 0, {
|
|
138
|
+
state: !watching ? 'off' : sn ? 'fresh' : 'never',
|
|
139
|
+
changed_at: sn ? iso(sn.changed_at) : null,
|
|
140
|
+
detail: !watching ? 'change detection is off for this repository' : sn ? 'checked every few seconds; the last change was read at once' : 'not looked at yet',
|
|
141
|
+
}));
|
|
142
|
+
const fs0 = o.factsStatus ? o.factsStatus(w.root) : null;
|
|
143
|
+
const factsAt = fs0 && fs0.as_of ? Date.parse(fs0.as_of) : 0;
|
|
144
|
+
let fstate = factsAt ? 'fresh' : 'never';
|
|
145
|
+
if (fs0 && fs0.building) fstate = 'building';
|
|
146
|
+
else if (fs0 && fs0.error) fstate = 'failed';
|
|
147
|
+
else if (factsAt && sn && sn.changed_at > factsAt) fstate = 'stale';
|
|
148
|
+
out.push(row(S.initiatives, factsAt, {
|
|
149
|
+
state: fstate,
|
|
150
|
+
detail: fs0 && fs0.error ? fs0.error : fs0 && fs0.took_ms != null ? `read in ${(fs0.took_ms / 1000).toFixed(1)} s` : null,
|
|
151
|
+
}));
|
|
152
|
+
out.push(row(S.calendar, o.stateDir ? calendarAsOf(o.stateDir) : 0));
|
|
153
|
+
out.push(row(S.jira, jiraAsOf(w.repoStateDir)));
|
|
154
|
+
const mw = o.stateDir ? readJson(path.join(o.stateDir, 'my-work', 'read.json'), null) : null;
|
|
155
|
+
out.push(row(S['my-work'], mw && mw.read_at ? Date.parse(mw.read_at) || 0 : 0));
|
|
156
|
+
const tr = scheduleOf('tracker-refresh');
|
|
157
|
+
out.push(row(S.tracker, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0));
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
start: () => watcher.start(), stop: () => watcher.stop(), tick: () => watcher.tick(),
|
|
163
|
+
versions: () => Object.fromEntries(versions), sources, enabled, setEnabled,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { create, SOURCES, calendarAsOf, jiraAsOf };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* offloop.js — run a synchronous fold in a worker thread, so a request does not hold the server (workspace-data-freshness R5).
|
|
3
|
+
*
|
|
4
|
+
* call(module, fn, args) → Promise<result> `module` is a file in workspace/lib; `fn` one of its exports; `args` and the
|
|
5
|
+
* result must survive structured clone (plain data).
|
|
6
|
+
* fallback(module, fn, args) → result the same call in this thread
|
|
7
|
+
* stop() end the worker (a test, or the server's exit)
|
|
8
|
+
*
|
|
9
|
+
* WHY. Board and My work fold every plan ledger and initiative record on each request: 0.2 to 0.5 s per workspace, all of
|
|
10
|
+
* it synchronous file reads. On the event loop that stalls every other request and every terminal's output for that long.
|
|
11
|
+
* In a worker it costs the same and stalls nothing.
|
|
12
|
+
*
|
|
13
|
+
* THE ANSWER IS THE SAME FUNCTION'S. The worker requires the same module and calls the same export with the same
|
|
14
|
+
* arguments; nothing is cached here. A worker that cannot start, crashes or times out rejects, and `run` then answers in
|
|
15
|
+
* this thread, so a route never fails because of the worker. One worker serves every call, one call at a time in order.
|
|
16
|
+
* Only modules in workspace/lib and only names in ALLOWED can be called.
|
|
17
|
+
*/
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const LIB = __dirname;
|
|
21
|
+
const ALLOWED = Object.freeze({ 'board-fold.js': ['boardFor'] });
|
|
22
|
+
const TIMEOUT_MS = 60000;
|
|
23
|
+
|
|
24
|
+
let worker = null;
|
|
25
|
+
let seq = 0;
|
|
26
|
+
const pending = new Map(); // id -> { resolve, reject, timer }
|
|
27
|
+
|
|
28
|
+
function allowed(mod, fn) { return Object.prototype.hasOwnProperty.call(ALLOWED, mod) && ALLOWED[mod].includes(fn); }
|
|
29
|
+
|
|
30
|
+
const WORKER_SRC = `
|
|
31
|
+
const { parentPort } = require('worker_threads');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
parentPort.on('message', (m) => {
|
|
34
|
+
try {
|
|
35
|
+
const out = require(path.join(${JSON.stringify(LIB)}, m.mod))[m.fn](...m.args);
|
|
36
|
+
parentPort.postMessage({ id: m.id, ok: true, out });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
parentPort.postMessage({ id: m.id, ok: false, error: String((e && e.message) || e) });
|
|
39
|
+
}
|
|
40
|
+
});`;
|
|
41
|
+
|
|
42
|
+
function failAll(err) {
|
|
43
|
+
for (const [id, p] of pending) { clearTimeout(p.timer); p.reject(err); pending.delete(id); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ensure() {
|
|
47
|
+
if (worker) return worker;
|
|
48
|
+
const { Worker } = require('worker_threads');
|
|
49
|
+
worker = new Worker(WORKER_SRC, { eval: true });
|
|
50
|
+
worker.unref();
|
|
51
|
+
worker.on('message', (m) => {
|
|
52
|
+
const p = pending.get(m.id);
|
|
53
|
+
if (!p) return;
|
|
54
|
+
pending.delete(m.id); clearTimeout(p.timer);
|
|
55
|
+
if (m.ok) p.resolve(m.out); else p.reject(new Error(m.error));
|
|
56
|
+
});
|
|
57
|
+
worker.on('error', (e) => { worker = null; failAll(e); });
|
|
58
|
+
worker.on('exit', () => { worker = null; failAll(new Error('the worker ended')); });
|
|
59
|
+
return worker;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function call(mod, fn, args = []) {
|
|
63
|
+
if (!allowed(mod, fn)) return Promise.reject(new Error(`offloop: ${mod}#${fn} is not allowed`));
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
let w;
|
|
66
|
+
try { w = ensure(); } catch (e) { reject(e); return; }
|
|
67
|
+
const id = ++seq;
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
pending.delete(id); reject(new Error('offloop: timed out'));
|
|
70
|
+
// a fold that hangs takes the worker with it; the next call starts a new one
|
|
71
|
+
try { w.terminate(); } catch { /* already gone */ }
|
|
72
|
+
}, TIMEOUT_MS);
|
|
73
|
+
if (timer.unref) timer.unref();
|
|
74
|
+
pending.set(id, { resolve, reject, timer });
|
|
75
|
+
w.ref(); // keep the process up while a call is in flight
|
|
76
|
+
w.postMessage({ id, mod, fn, args });
|
|
77
|
+
}).finally(() => { if (worker && !pending.size) worker.unref(); });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fallback(mod, fn, args = []) {
|
|
81
|
+
if (!allowed(mod, fn)) throw new Error(`offloop: ${mod}#${fn} is not allowed`);
|
|
82
|
+
return require(path.join(LIB, mod))[fn](...args);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The worker's answer, or this thread's when the worker fails. PMOS_OFFLOOP=0 answers in this thread (a suite that
|
|
86
|
+
* replaces a module's export in its own process needs the fold to see the replacement). */
|
|
87
|
+
async function run(mod, fn, args = []) {
|
|
88
|
+
if (process.env.PMOS_OFFLOOP === '0') return fallback(mod, fn, args);
|
|
89
|
+
try { return await call(mod, fn, args); } catch { return fallback(mod, fn, args); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function stop() { if (worker) { const w = worker; worker = null; failAll(new Error('stopped')); return w.terminate(); } return Promise.resolve(); }
|
|
93
|
+
|
|
94
|
+
module.exports = { call, run, fallback, stop, ALLOWED };
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* and the fold of the whole file is the current list. Stage is never stored; a list is derived from the due
|
|
7
7
|
* date, priority and waiting-on fields by placement() below.
|
|
8
8
|
*
|
|
9
|
+
* The Work page (console3) reads a second placement, workPlacement(), into five views: inbox, today, upcoming,
|
|
10
|
+
* waiting, someday. placement() and its five lists stay for the old routes. A sixth view, Review, is a queue
|
|
11
|
+
* (reviewQueue) of items nobody has touched for a week. fold() stamps `touched_at` from the event's `at`.
|
|
12
|
+
*
|
|
9
13
|
* SINGLE WRITER: appendEvent() is the only function that writes the file. Callers pass the event; they never
|
|
10
14
|
* choose `at` or `by` themselves (attribution comes from the call path, as in plan-items.js).
|
|
11
15
|
*/
|
|
@@ -14,6 +18,11 @@ const path = require('path');
|
|
|
14
18
|
|
|
15
19
|
const UNDO_MS = 8000;
|
|
16
20
|
const LISTS = ['waiting', 'today', 'week', 'triage', 'later'];
|
|
21
|
+
const WORK_LISTS = ['inbox', 'today', 'upcoming', 'waiting', 'someday'];
|
|
22
|
+
const REVIEW_DAYS = 7;
|
|
23
|
+
const REVIEW_FROM = ['inbox', 'someday', 'waiting'];
|
|
24
|
+
// A legacy list_override (set by /move) maps onto the Work views.
|
|
25
|
+
const LEGACY_TO_WORK = { today: 'today', triage: 'inbox', waiting: 'waiting', week: 'upcoming', later: 'someday' };
|
|
17
26
|
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
18
27
|
|
|
19
28
|
const DUE_WORD = /\b(?:due|by)\s+(today|tomorrow|sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?)\b/i;
|
|
@@ -66,15 +75,71 @@ function placement(item, today) {
|
|
|
66
75
|
return 'later';
|
|
67
76
|
}
|
|
68
77
|
|
|
78
|
+
// Place an item in one of the five Work views (console3 PRD R2). The one place this is decided; the client never
|
|
79
|
+
// copies an item between views. `today` is a Date. Rules, in order: a legacy override, parked is Someday, waiting
|
|
80
|
+
// on someone is Waiting, a defer date after today is Upcoming, flagged is Today, due today or earlier is Today,
|
|
81
|
+
// a later due date is Upcoming, no date is Inbox. One addition to R2, for R6 ("the item returns to Today on that
|
|
82
|
+
// date"): an undated item whose defer date has arrived is Today, not Inbox.
|
|
83
|
+
function workPlacement(item, today) {
|
|
84
|
+
if (item.list_override && LEGACY_TO_WORK[item.list_override]) return LEGACY_TO_WORK[item.list_override];
|
|
85
|
+
if (item.parked) return 'someday';
|
|
86
|
+
if (item.waiting_on) return 'waiting';
|
|
87
|
+
const t = iso(today);
|
|
88
|
+
if (item.defer && item.defer > t) return 'upcoming';
|
|
89
|
+
if (item.flagged) return 'today';
|
|
90
|
+
if (item.due && item.due <= t) return 'today';
|
|
91
|
+
if (item.due) return 'upcoming';
|
|
92
|
+
if (item.defer) return 'today';
|
|
93
|
+
return 'inbox';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PRIORITY_RANK = { P1: 1, P2: 2, P3: 3 };
|
|
97
|
+
const prio = (i) => PRIORITY_RANK[i.priority] || 9;
|
|
98
|
+
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
99
|
+
// The day an Upcoming item arrives: its defer date while that is still ahead, else its due date.
|
|
100
|
+
function arrivesOn(item, todayIso) { return item.defer && item.defer > todayIso ? item.defer : (item.due || ''); }
|
|
101
|
+
|
|
102
|
+
// Sort one Work view. today: overdue first (oldest first), then flagged, then by due date. upcoming: by arrival
|
|
103
|
+
// day. inbox and someday: newest first. waiting: the one waited on longest first.
|
|
104
|
+
function sortWorkList(name, items, today) {
|
|
105
|
+
const t = iso(today);
|
|
106
|
+
const byNew = (a, b) => cmpStr(b.created_at || '', a.created_at || '');
|
|
107
|
+
const out = [...items];
|
|
108
|
+
if (name === 'today') {
|
|
109
|
+
const group = (i) => (i.due && i.due < t ? 0 : i.flagged ? 1 : 2);
|
|
110
|
+
out.sort((a, b) => group(a) - group(b) || cmpStr(a.due || '9999', b.due || '9999') || prio(a) - prio(b) || byNew(a, b));
|
|
111
|
+
} else if (name === 'upcoming') {
|
|
112
|
+
out.sort((a, b) => cmpStr(arrivesOn(a, t) || '9999', arrivesOn(b, t) || '9999') || prio(a) - prio(b) || byNew(a, b));
|
|
113
|
+
} else if (name === 'waiting') {
|
|
114
|
+
out.sort((a, b) => cmpStr(a.waiting_since || a.created_at || '', b.waiting_since || b.created_at || ''));
|
|
115
|
+
} else out.sort(byNew);
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The review queue: open items in Inbox, Someday or Waiting that nobody touched for `days` days, most stale first.
|
|
120
|
+
// `now` is a Date. An item with no readable touched_at is left out, never guessed.
|
|
121
|
+
function reviewQueue(items, now, days = REVIEW_DAYS) {
|
|
122
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
123
|
+
const cutoff = now.getTime() - days * 86400000;
|
|
124
|
+
const stamp = (i) => Date.parse(i.touched_at || i.created_at || '');
|
|
125
|
+
return items
|
|
126
|
+
.filter((i) => !i.done && REVIEW_FROM.includes(workPlacement(i, today)) && Number.isFinite(stamp(i)) && stamp(i) <= cutoff)
|
|
127
|
+
.sort((a, b) => stamp(a) - stamp(b));
|
|
128
|
+
}
|
|
129
|
+
|
|
69
130
|
// Fold the event lines into the current items, keyed by id. A dropped or deleted item is removed.
|
|
70
131
|
function fold(lines) {
|
|
71
132
|
const items = new Map();
|
|
72
133
|
for (const ev of lines) {
|
|
73
134
|
if (!ev || !ev.id) continue;
|
|
74
|
-
if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at });
|
|
75
|
-
else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields });
|
|
76
|
-
else if (ev.event === 'done' && items.has(ev.id))
|
|
77
|
-
|
|
135
|
+
if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at, touched_at: ev.at });
|
|
136
|
+
else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields, touched_at: ev.at });
|
|
137
|
+
else if (ev.event === 'done' && items.has(ev.id)) {
|
|
138
|
+
const outcome = typeof ev.outcome === 'string' && ev.outcome.trim() ? { outcome: ev.outcome.trim() } : {};
|
|
139
|
+
items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at, touched_at: ev.at, ...outcome });
|
|
140
|
+
}
|
|
141
|
+
else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null, touched_at: ev.at });
|
|
142
|
+
else if (ev.event === 'kept' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), touched_at: ev.at });
|
|
78
143
|
else if (ev.event === 'dropped' || ev.event === 'deleted') items.delete(ev.id);
|
|
79
144
|
}
|
|
80
145
|
return [...items.values()];
|
|
@@ -92,7 +157,7 @@ function removalEvent(item, now) {
|
|
|
92
157
|
function undoEvent(lines, id, now = new Date()) {
|
|
93
158
|
const removal = [...lines].reverse().find((ev) => ev && ev.id === id && (ev.event === 'deleted' || ev.event === 'dropped'));
|
|
94
159
|
if (!removal || !removal.snapshot || !canUndo(removal.at, now)) return null;
|
|
95
|
-
const { id: _id, source, created_at, ...fields } = removal.snapshot;
|
|
160
|
+
const { id: _id, source, created_at, touched_at, ...fields } = removal.snapshot;
|
|
96
161
|
return { event: 'created', id, fields, source: source || 'local', restored: true };
|
|
97
162
|
}
|
|
98
163
|
|
|
@@ -112,4 +177,4 @@ function readLines(file) {
|
|
|
112
177
|
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
|
|
113
178
|
}
|
|
114
179
|
|
|
115
|
-
module.exports = { UNDO_MS, LISTS, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
|
|
180
|
+
module.exports = { UNDO_MS, LISTS, WORK_LISTS, REVIEW_DAYS, workPlacement, sortWorkList, reviewQueue, arrivesOn, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
|
|
@@ -47,6 +47,9 @@ const VERBS = Object.freeze({
|
|
|
47
47
|
// Not a pm-os-absorb verb (run: null): initiative my-work added it through the same table.
|
|
48
48
|
'my-work': Object.freeze({ module: 'my-work-cli.js', run: null, guide: 'my-work.md',
|
|
49
49
|
help: 'pmos my-work list|show|submit … — the Jira tickets and WBS items assigned to you, ranked (pmos my-work help)' }),
|
|
50
|
+
// Not a pm-os-absorb verb (run: null): workspace-data-freshness added it through the same table.
|
|
51
|
+
calendar: Object.freeze({ module: 'calendar-cli.js', run: null, guide: 'data-freshness.md',
|
|
52
|
+
help: 'pmos calendar import|status — the calendar events an agent session read, saved for Briefing and Calendar (pmos calendar help)' }),
|
|
50
53
|
launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
|
|
51
54
|
help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
|
|
52
55
|
});
|
|
@@ -216,4 +216,4 @@ function sig(w) {
|
|
|
216
216
|
return [reportsOf(w.root).sig, schedulesStamp(), kill, hour].join(':');
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|
|
219
|
+
module.exports = { kinds, rowsFor, sig, rowsAt, reportsOf, readSchedules, cadenceOfSkill, cadenceOfSchedule, blobSha, reportsDir, KILL_FILE, GRACE_MS };
|
|
@@ -61,6 +61,7 @@ const MODULES = Object.freeze([
|
|
|
61
61
|
'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
|
|
62
62
|
'routes-c3-reports', // console3 Reports dashboards: library, per-scope data, page-press layouts (layouts are the only write)
|
|
63
63
|
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
64
|
+
'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
|
|
64
65
|
]);
|
|
65
66
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
66
67
|
const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
|
|
@@ -24,6 +24,7 @@ const grantMint = require('./grant-mint.js');
|
|
|
24
24
|
const factoryJoin = require('./factory-join.js');
|
|
25
25
|
const planItems = require('./plan-items.js');
|
|
26
26
|
const boardFold = require('./board-fold.js');
|
|
27
|
+
const offloop = require('./offloop.js');
|
|
27
28
|
|
|
28
29
|
const BATCH_RE = /^[A-Za-z0-9][\w.-]{0,79}$/;
|
|
29
30
|
const text = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -46,7 +47,8 @@ async function handle(req, res, url, ctx) {
|
|
|
46
47
|
return true;
|
|
47
48
|
}
|
|
48
49
|
let out;
|
|
49
|
-
|
|
50
|
+
// in a worker thread (workspace-data-freshness R5): the same fold, without holding the server while it reads
|
|
51
|
+
try { out = await offloop.run('board-fold.js', 'boardFor', [{ id: w.id, root: w.root }, { filter: url.searchParams.get('filter') || 'active' }]); }
|
|
50
52
|
catch (e) { ctx.json(res, { ok: false, error: 'the Board could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500); return true; }
|
|
51
53
|
if (!out.ok) { ctx.json(res, { ok: false, error: out.error }, out.status || 400); return true; }
|
|
52
54
|
ctx.json(res, { ...out, ws: w.id });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
/* routes-calendar-views.js — GET /api/calendar-views?view=day|week|month|quarter|year&date=YYYY-MM-DD&ws=<id> (console3 wave C2).
|
|
3
3
|
*
|
|
4
|
-
* → { ok, view, range, cells, unit, gantt, strategy, events_source
|
|
4
|
+
* → { ok, view, range, cells, unit, gantt, strategy, events_source, today,
|
|
5
|
+
* layers, layers_total, layers_note, free, context, context_total } (the last six: lib/calendar-layers.js)
|
|
5
6
|
*
|
|
6
7
|
* READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
|
|
7
8
|
* the handler so a failure answers 500 { ok:false } and never reaches the server. The date and the plan come from the records:
|
|
@@ -13,10 +14,12 @@
|
|
|
13
14
|
* gets none and says so. */
|
|
14
15
|
const path = require('path');
|
|
15
16
|
const model = require('../../scripts/calendar-model.js');
|
|
17
|
+
const layersLib = require('./calendar-layers.js');
|
|
16
18
|
|
|
17
19
|
const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
|
|
18
20
|
|
|
19
|
-
/** The pure handler. deps: { readFacts(
|
|
21
|
+
/** The pure handler. deps: { readFacts(), readEvents?(range), readKrs?(), today, nowMs?, readDated?(range), readItems?(),
|
|
22
|
+
* readContextSources?() } so tests need no repo. A dep that is missing gives its source as empty, said in layers_note. */
|
|
20
23
|
function handle(query, deps = {}) {
|
|
21
24
|
const view = (query && query.view) || 'month';
|
|
22
25
|
const date = (query && query.date) || (deps.today || today());
|
|
@@ -33,13 +36,118 @@ function handle(query, deps = {}) {
|
|
|
33
36
|
const nowDay = deps.today || today();
|
|
34
37
|
const gantt = model.ganttRows(inis, view, date, { today: nowDay });
|
|
35
38
|
const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
|
|
36
|
-
|
|
39
|
+
const dated = typeof deps.readDated === 'function' ? (deps.readDated(range) || {}) : {
|
|
40
|
+
unavailable: { plan_items: 'no reader is wired', releases: 'no reader is wired', reports: 'no reader is wired', own: 'no reader is wired' } };
|
|
41
|
+
// the initiatives come from the facts this handler already holds, so every caller gets their promised dates
|
|
42
|
+
if (!dated.initiatives && !(dated.unavailable && dated.unavailable.initiatives)) dated.initiatives = inis;
|
|
43
|
+
const items = typeof deps.readItems === 'function' ? (deps.readItems() || {}) : {};
|
|
44
|
+
const src = typeof deps.readContextSources === 'function' ? (deps.readContextSources() || {}) : {};
|
|
45
|
+
const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
|
|
46
|
+
const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems });
|
|
47
|
+
return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra } };
|
|
37
48
|
} catch (e) {
|
|
38
49
|
return { status: 500, body: { ok: false, error: String(e && e.message || e) } };
|
|
39
50
|
}
|
|
40
51
|
}
|
|
41
52
|
|
|
53
|
+
/* ── the readers behind the layers, free time and context (each source on its own: one that cannot be read is named in
|
|
54
|
+
* layers_note and gives nothing; it never takes the answer down) ────────────────────────────────────────────────── */
|
|
55
|
+
const reason = (e) => String((e && e.message) || e).slice(0, 160);
|
|
56
|
+
|
|
57
|
+
function readers(root, runtimeDir) {
|
|
58
|
+
let plan = null;
|
|
59
|
+
const planFold = () => { if (!plan) plan = require('./plan-items.js').readFold(root); return plan; };
|
|
60
|
+
const planRows = () => Object.values(planFold().items).filter((p) => !(p.proposed && !p.kept));
|
|
61
|
+
let ownLines = null;
|
|
62
|
+
const ownRows = () => {
|
|
63
|
+
if (!ownLines) ownLines = require('./pm-items.js').readLines(path.join(runtimeDir, 'pm-items', 'items.jsonl'));
|
|
64
|
+
return require('./pm-items.js').fold(ownLines);
|
|
65
|
+
};
|
|
66
|
+
const planItem = (p) => ({ id: p.id, initiative: p.initiative, title: p.title, due: p.due, milestone: !!p.milestone, state: p.state });
|
|
67
|
+
return {
|
|
68
|
+
readDated(range) {
|
|
69
|
+
const unavailable = {};
|
|
70
|
+
const out = { unavailable };
|
|
71
|
+
try { out.planItems = planRows().map(planItem); } catch (e) { unavailable.plan_items = 'the plan ledger could not be read: ' + reason(e); }
|
|
72
|
+
try {
|
|
73
|
+
const R = require('../../scripts/releases.js');
|
|
74
|
+
const f = path.join(runtimeDir, 'releases', 'releases.jsonl');
|
|
75
|
+
let lines = []; try { lines = require('fs').readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { lines = []; }
|
|
76
|
+
out.releases = R.foldReleases(lines);
|
|
77
|
+
} catch (e) { unavailable.releases = 'the releases file could not be read: ' + reason(e); }
|
|
78
|
+
try {
|
|
79
|
+
const RN = require('./report-needs.js');
|
|
80
|
+
out.reports = RN.reportsOf(root).reports.map((r) => ({ cadence: r.cadence, slot: r.slot, issued: r.issued ? { at: r.issued.at } : null }));
|
|
81
|
+
out.reportSchedules = reportSchedules(RN, range);
|
|
82
|
+
} catch (e) { unavailable.reports = 'the report folders could not be read: ' + reason(e); }
|
|
83
|
+
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done })); } catch (e) { unavailable.own = 'your items could not be read: ' + reason(e); }
|
|
84
|
+
return out;
|
|
85
|
+
},
|
|
86
|
+
readItems() {
|
|
87
|
+
const out = { own: [], planItems: [] };
|
|
88
|
+
try { out.planItems = planRows().map(planItem); } catch { /* the plan ledger is named in layers_note */ }
|
|
89
|
+
try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
|
|
90
|
+
return out;
|
|
91
|
+
},
|
|
92
|
+
readContextSources() {
|
|
93
|
+
const src = { notes: [], planItems: [], ownItems: [] };
|
|
94
|
+
try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
|
|
95
|
+
try { src.ownItems = ownRows().map((o) => ({ id: o.id, title: o.title, initiative: o.initiative || null, created_at: o.created_at || null, done_at: o.done_at || null })); } catch { /* no own items */ }
|
|
96
|
+
try { src.notes = linkedNotes(runtimeDir); } catch { /* no notes: no decisions or actions */ }
|
|
97
|
+
return src;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The dates a report schedule fires on, from the range start, using the same slot math the scheduler runs on. */
|
|
103
|
+
function reportSchedules(RN, range) {
|
|
104
|
+
const logic = require('./schedule-logic.js');
|
|
105
|
+
const out = [];
|
|
106
|
+
const t0 = new Date();
|
|
107
|
+
const today = `${t0.getFullYear()}-${String(t0.getMonth() + 1).padStart(2, '0')}-${String(t0.getDate()).padStart(2, '0')}`;
|
|
108
|
+
const first = range && range.start > today ? range.start : today;
|
|
109
|
+
const from = new Date(Number(first.slice(0, 4)), Number(first.slice(5, 7)) - 1, Number(first.slice(8, 10)));
|
|
110
|
+
for (const s of RN.readSchedules()) {
|
|
111
|
+
if (!s || !s.enabled) continue;
|
|
112
|
+
const cadence = RN.cadenceOfSchedule(s);
|
|
113
|
+
if (!cadence) continue;
|
|
114
|
+
const dates = [];
|
|
115
|
+
let at = from;
|
|
116
|
+
for (let i = 0; i < 62; i++) {
|
|
117
|
+
const next = logic.nextRun(s, at);
|
|
118
|
+
if (!next) break;
|
|
119
|
+
const d = new Date(next);
|
|
120
|
+
const ds = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
121
|
+
if (range && ds > range.end) break;
|
|
122
|
+
dates.push(ds);
|
|
123
|
+
at = new Date(d.getTime() + 1000);
|
|
124
|
+
}
|
|
125
|
+
out.push({ id: String(s.id || s.name || cadence), name: s.name, cadence, dates: [...new Set(dates)] });
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Notes linked to a calendar event, read straight from the notes folder (no import, no write). */
|
|
131
|
+
function linkedNotes(runtimeDir) {
|
|
132
|
+
const fs = require('fs');
|
|
133
|
+
const dir = path.join(runtimeDir, 'notes');
|
|
134
|
+
const out = [];
|
|
135
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.json'))) {
|
|
136
|
+
let n; try { n = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch { continue; }
|
|
137
|
+
if (!n || !n.id || !n.meeting || n.meeting.kind !== 'event') continue;
|
|
138
|
+
const fin = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
|
|
139
|
+
const sec = n.summary && n.summary.sections ? n.summary.sections : null;
|
|
140
|
+
const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
|
|
141
|
+
out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null,
|
|
142
|
+
meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title },
|
|
143
|
+
decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
|
|
144
|
+
actions: pick(fin && fin.actions, sec && sec.actions).map((x) => ({ text: x && x.text, owner: x && x.owner || null, due: x && x.due || null })) });
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
42
149
|
module.exports = {
|
|
150
|
+
readers,
|
|
43
151
|
prefixes: ['/api/calendar-views'],
|
|
44
152
|
handlePure: handle,
|
|
45
153
|
handle: async (req, res, url, ctx) => {
|
|
@@ -54,6 +162,7 @@ module.exports = {
|
|
|
54
162
|
const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
|
|
55
163
|
readFacts: () => facts,
|
|
56
164
|
readEvents: () => require('./routes-calendar-import.js').readEvents(ctx.runtimeDir),
|
|
165
|
+
...readers(root, ctx.runtimeDir),
|
|
57
166
|
});
|
|
58
167
|
ctx.json(res, out.body, out.status);
|
|
59
168
|
return true;
|
|
@@ -22,6 +22,7 @@ const path = require('path');
|
|
|
22
22
|
const guard = require('./pm-guard.js');
|
|
23
23
|
const myWork = require('./my-work.js');
|
|
24
24
|
const boardFold = require('./board-fold.js');
|
|
25
|
+
const offloop = require('./offloop.js');
|
|
25
26
|
const { localDate } = require('./brief.js');
|
|
26
27
|
|
|
27
28
|
const dirOf = (ctx) => path.join(ctx.runtimeDir, 'my-work');
|
|
@@ -37,16 +38,33 @@ function loadActions(ctx) {
|
|
|
37
38
|
|
|
38
39
|
/** The whole list, as GET answers it. */
|
|
39
40
|
function listFor(ctx, now) {
|
|
41
|
+
const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
|
|
42
|
+
return foldList(ctx, now, all.map((w) => {
|
|
43
|
+
try { return { w, b: boardFold.boardFor({ id: w.id, root: w.root }, { filter: 'mine', now }) }; }
|
|
44
|
+
catch (e) { return { w, error: e }; }
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* The same list with each repository's Board fold run in a worker thread (workspace-data-freshness R5): the GET answers
|
|
49
|
+
* what listFor answers, without holding the server for a fold per repository. */
|
|
50
|
+
async function listForAsync(ctx, now) {
|
|
51
|
+
const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
|
|
52
|
+
const folds = [];
|
|
53
|
+
for (const w of all) {
|
|
54
|
+
try { folds.push({ w, b: await offloop.run('board-fold.js', 'boardFor', [{ id: w.id, root: w.root }, { filter: 'mine', now }]) }); }
|
|
55
|
+
catch (e) { folds.push({ w, error: e }); }
|
|
56
|
+
}
|
|
57
|
+
return foldList(ctx, now, folds);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function foldList(ctx, now, folds) {
|
|
40
61
|
const today = localDate(now);
|
|
41
62
|
const banner = [];
|
|
42
63
|
const boards = [];
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
|
|
48
|
-
else banner.push({ source: 'wbs:' + w.id, error: b.error });
|
|
49
|
-
} catch (e) { banner.push({ source: 'wbs:' + w.id, error: String((e && e.message) || e).slice(0, 160) }); }
|
|
64
|
+
for (const { w, b, error } of folds) {
|
|
65
|
+
if (error) { banner.push({ source: 'wbs:' + w.id, error: String((error && error.message) || error).slice(0, 160) }); continue; }
|
|
66
|
+
if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
|
|
67
|
+
else banner.push({ source: 'wbs:' + w.id, error: b.error });
|
|
50
68
|
}
|
|
51
69
|
const read = loadRead(ctx);
|
|
52
70
|
const actions = loadActions(ctx);
|
|
@@ -71,7 +89,7 @@ async function handle(req, res, url, ctx) {
|
|
|
71
89
|
const now = Date.now();
|
|
72
90
|
if (p === '/api/my-work') {
|
|
73
91
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
74
|
-
ctx.json(res,
|
|
92
|
+
ctx.json(res, await listForAsync(ctx, now));
|
|
75
93
|
return true;
|
|
76
94
|
}
|
|
77
95
|
if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh'].includes(p)) return false;
|
|
@@ -119,4 +137,4 @@ async function handle(req, res, url, ctx) {
|
|
|
119
137
|
return true;
|
|
120
138
|
}
|
|
121
139
|
|
|
122
|
-
module.exports = { prefixes: ['/api/my-work'], handle, listFor };
|
|
140
|
+
module.exports = { prefixes: ['/api/my-work'], handle, listFor, listForAsync };
|