create-pmos 0.131.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/guide/data-freshness.md +40 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/calendar-cli.js +84 -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/pmos-verbs.js +3 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-board.js +3 -1
- package/workspace/lib/routes-my-work.js +27 -9
- package/workspace/lib/routes-sources.js +64 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-shell.js +4 -1
- package/workspace/public/pm/components.css +33 -0
- package/workspace/public/pm/dest-settings.js +10 -1
- package/workspace/public/pm/freshness-line.js +123 -0
- package/workspace/server.js +39 -2
- package/workspace/skills/calendar-sync.skill +30 -0
- package/workspace/test-lists/workspace-data-freshness.json +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.132.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:03:34Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:03:34Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T04:03:34Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Data freshness: how the workspace keeps up to date, and how old each source is"
|
|
4
|
+
timestamp: 2026-10-11
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Data freshness
|
|
8
|
+
|
|
9
|
+
The workspace reads your repositories, and your agents read Jira, your calendar and My work for it. This page says when each one is read again and where you see how old it is.
|
|
10
|
+
|
|
11
|
+
## Your repositories
|
|
12
|
+
|
|
13
|
+
The workspace checks each registered repository every 10 seconds for a change: a commit, a checkout, a pull, or a file saved by you or an agent. The check is two read-only git commands. When something changed and then stays the same for 3 seconds, the workspace reads the repository again. It reads nothing when nothing changed.
|
|
14
|
+
|
|
15
|
+
- **Initiatives** (Briefing, Calendar and the reports) are read in a separate process, so the workspace keeps answering while it reads. While a read runs, the pages show the last result. The result is kept on this machine, so after a restart the pages open at once with the last result and a new read starts only if the repository changed.
|
|
16
|
+
- **Board and My work** are folded in a separate thread for the same reason.
|
|
17
|
+
|
|
18
|
+
To stop the checks for one repository, press **Stop watching this repository** in Settings › Data freshness. The pages then show what was read last, and **Refresh** reads it again. Press **Watch this repository for changes** to start the checks again.
|
|
19
|
+
|
|
20
|
+
## Jira, calendar, My work and tracker items
|
|
21
|
+
|
|
22
|
+
The workspace holds no password or token, and it never calls these services. An agent session reads each one with your own connection, on a schedule or when you press **Refresh**:
|
|
23
|
+
|
|
24
|
+
| Source | Skill | Default schedule | Shown as old after |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| Calendar | calendar-sync | 06:30 and 12:30 every day (set `calendar`) | 8 hours |
|
|
27
|
+
| Jira status | jira-sync | 06:30 every day (set `jira`) | 26 hours |
|
|
28
|
+
| My work | my-work-read | when you press Read again | 26 hours |
|
|
29
|
+
| Tracker items | tracker-refresh | Friday 15:30 (set `tracker`) | 8 days |
|
|
30
|
+
|
|
31
|
+
The calendar-sync skill reads today and the next 14 days, read-only, and saves the events with this command:
|
|
32
|
+
|
|
33
|
+
- `pmos calendar import --from <file|-> --sensitivity <label>` saves the events the agent read. The label is `public`, `internal`, `confidential` or `private`. `--dry-run` checks the file and saves nothing.
|
|
34
|
+
- `pmos calendar status` says how many events are saved and when they were read.
|
|
35
|
+
|
|
36
|
+
The events are kept in your own runtime folder on this machine, never in a repository.
|
|
37
|
+
|
|
38
|
+
## Where you see it
|
|
39
|
+
|
|
40
|
+
Each page shows a short **as of** line for the sources it reads. The line turns amber when a source is older than its limit, and says **never read** when nothing was read yet. Settings › Data freshness shows every source in one table: when it was read, its state, its schedule, and **Refresh**.
|
package/workspace/guide/index.md
CHANGED
|
@@ -43,6 +43,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
43
43
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
44
44
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
|
45
45
|
- [My work](my-work.md): the work assigned to you, its order, the brief to an agent, and `pmos my-work`.
|
|
46
|
+
- [Data freshness](data-freshness.md): when each source is read again, how old it is, and `pmos calendar`.
|
|
46
47
|
- [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
|
|
47
48
|
- [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
|
|
48
49
|
- [Calendar popup](calendar-popup.md): open the full calendar over any page of the console with the calendar button or the C key, and close it.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* calendar-cli.js — `pmos calendar …`: the calendar events an agent session read, saved for Briefing and Calendar
|
|
3
|
+
* (workspace-data-freshness R8).
|
|
4
|
+
* pmos calendar import --from <file|-> --sensitivity public|internal|confidential|private [--dry-run] [--json]
|
|
5
|
+
* pmos calendar status [--json]
|
|
6
|
+
* This command never talks to a calendar. The calendar-sync skill reads it in an agent session, with the PM's own
|
|
7
|
+
* connection, and hands the events to `import`. The import goes through scripts/calendar-import.js unchanged: hidden text
|
|
8
|
+
* is stripped, every line is marked, the raw text is hashed, and a missing sensitivity is refused. The events are kept in
|
|
9
|
+
* the per-user runtime folder, never in a repository.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const stateRoot = require('./state-root');
|
|
14
|
+
const CI = require('../../scripts/calendar-import.js');
|
|
15
|
+
const L = require('../../scripts/trust/ledger.js');
|
|
16
|
+
|
|
17
|
+
const COMMANDS = { calendar: ['import', 'status'] };
|
|
18
|
+
const USAGE = [
|
|
19
|
+
'usage: pmos calendar import|status — the calendar events an agent session read for Briefing and Calendar',
|
|
20
|
+
' pmos calendar import --from <file|-> --sensitivity <label> [--dry-run] save the events (the calendar-sync skill runs this)',
|
|
21
|
+
' pmos calendar status how many events are saved, and when they were read',
|
|
22
|
+
' every command: [--json]',
|
|
23
|
+
'This command never calls a calendar. Your agent reads it with your own connection.',
|
|
24
|
+
];
|
|
25
|
+
const BOOL = new Set(['json', 'dry-run', 'help']);
|
|
26
|
+
const VALUE = new Set(['from', 'sensitivity']);
|
|
27
|
+
|
|
28
|
+
function parseArgs(argv) {
|
|
29
|
+
const out = { _: [] };
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const t = argv[i];
|
|
32
|
+
if (!t.startsWith('--')) { out._.push(t); continue; }
|
|
33
|
+
const eq = t.indexOf('=');
|
|
34
|
+
const key = eq > 0 ? t.slice(2, eq) : t.slice(2);
|
|
35
|
+
if (BOOL.has(key)) out[key] = true;
|
|
36
|
+
else if (eq > 0) out[key] = t.slice(eq + 1);
|
|
37
|
+
else if (VALUE.has(key) && argv[i + 1] !== undefined) out[key] = argv[++i];
|
|
38
|
+
else out[key] = '';
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ledgerOf = (dir) => path.join(dir, 'calendar-import', 'events.jsonl');
|
|
44
|
+
|
|
45
|
+
function readInput(from, stdin) {
|
|
46
|
+
if (from === '-') return stdin();
|
|
47
|
+
return fs.readFileSync(path.resolve(from), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function run(argv, { say = console.log, env = process.env, stateDir, stdin = () => fs.readFileSync(0, 'utf8'), now = () => new Date(), isTTY } = {}) {
|
|
51
|
+
const a = parseArgs(argv.slice(1));
|
|
52
|
+
const sub = a._ && a._[0];
|
|
53
|
+
const out = (o, line) => { if (a.json) say(JSON.stringify(o)); else say(line); };
|
|
54
|
+
if (!sub || a.help || sub === 'help') { USAGE.forEach((l) => say(l)); return sub || a.help ? 0 : 2; }
|
|
55
|
+
if (!COMMANDS.calendar.includes(sub)) { say(`✗ pmos calendar ${sub} is not a command`); USAGE.forEach((l) => say(l)); return 2; }
|
|
56
|
+
const dir = stateDir || stateRoot.defaultRoot({ env });
|
|
57
|
+
const file = ledgerOf(dir);
|
|
58
|
+
|
|
59
|
+
if (sub === 'status') {
|
|
60
|
+
const events = CI.foldEvents(CI.readLines(file));
|
|
61
|
+
const last = events.reduce((m, e) => (e.fetched_at && e.fetched_at > m ? e.fetched_at : m), '');
|
|
62
|
+
out({ ok: true, events: events.length, read_at: last || null }, events.length ? `${events.length} events saved, last read ${last}` : 'no calendar events are saved yet');
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!a.from) { say('✗ --from <file|-> is required'); return 2; }
|
|
67
|
+
let raw;
|
|
68
|
+
try { raw = JSON.parse(readInput(a.from, stdin)); } catch (e) { say(`✗ the events file is not JSON: ${String(e.message).slice(0, 160)}`); return 1; }
|
|
69
|
+
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.events) ? raw.events : null;
|
|
70
|
+
if (!list) { say('✗ the file must hold a list of events, or { "events": [ … ] }'); return 1; }
|
|
71
|
+
const at = now().toISOString();
|
|
72
|
+
let evs;
|
|
73
|
+
try { evs = CI.importEvents(list, { fetchedAt: at, sensitivity: a.sensitivity }); }
|
|
74
|
+
catch (e) { say(`✗ ${e.message}`); return 1; }
|
|
75
|
+
if (a['dry-run']) { out({ ok: true, dryRun: true, count: evs.length }, `would save ${evs.length} events (dry run, nothing written)`); return 0; }
|
|
76
|
+
const actor = L.actorFrom({ env, isTTY: isTTY == null ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY });
|
|
77
|
+
try { CI.appendImport(file, evs, actor, at); }
|
|
78
|
+
catch (e) { say(`✗ the events could not be saved: ${String(e.message).slice(0, 160)}`); return 1; }
|
|
79
|
+
const flagged = evs.filter((e) => e.findings && e.findings.length).length;
|
|
80
|
+
out({ ok: true, count: evs.length, flagged, read_at: at }, `✓ saved ${evs.length} calendar events${flagged ? ` (${flagged} carried hidden or suspicious text, marked)` : ''}`);
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { run, COMMANDS, USAGE };
|
|
@@ -1,51 +1,160 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server
|
|
2
|
+
/* facts-async.js — the repo's initiative facts (scripts/signals/facts.js readRepoFacts) WITHOUT blocking the server, and
|
|
3
|
+
* read again only when the repository changed (workspace-data-freshness R2, R3).
|
|
3
4
|
*
|
|
4
|
-
* readRepoFacts derives every initiative's stage from git
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* readRepoFacts derives every initiative's stage from git and is synchronous, so it runs in a child process
|
|
6
|
+
* (scripts/signals/facts-cli.js). One read in flight is shared between callers.
|
|
7
|
+
*
|
|
8
|
+
* ONE ENTRY PER REPOSITORY. The entry is not keyed by day: a caller that passes `today` gets the same facts normalized for
|
|
9
|
+
* that day (normalizeFacts is pure and cheap), so Briefing and Calendar share one read instead of running two.
|
|
10
|
+
*
|
|
11
|
+
* REFRESHED ON A CHANGE, NOT ON A CLOCK. The entry carries the repository's signature (lib/repo-watch.js: HEAD, the
|
|
12
|
+
* porcelain status, and the size and time of each dirty file) and the engine version (a hash of the scripts that derive
|
|
13
|
+
* the facts). A read whose signature and engine match is served from the entry. A changed signature returns the entry at
|
|
14
|
+
* once and starts one rebuild (stale while revalidate); only a repository never read before waits. An entry older than
|
|
15
|
+
* MAX_AGE_MS is rebuilt in the background even when the signature holds, which catches an input the signature cannot see
|
|
16
|
+
* (an ignored file). A folder that is not a git checkout has no signature and keeps the old rule: a TTL of TTL_MS.
|
|
17
|
+
*
|
|
18
|
+
* KEPT ON DISK. configure({ stateDir }) names the runtime state folder; each entry is then written to
|
|
19
|
+
* <stateDir>/facts-cache/<hash of root>.json and read back after a restart. Never into the repository. Unconfigured (a
|
|
20
|
+
* suite), nothing is written.
|
|
9
21
|
*/
|
|
10
22
|
const { execFile } = require('child_process');
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const fs = require('fs');
|
|
11
25
|
const path = require('path');
|
|
26
|
+
const repoWatch = require('./repo-watch.js');
|
|
12
27
|
|
|
13
28
|
const CLI = path.resolve(__dirname, '..', '..', 'scripts', 'signals', 'facts-cli.js');
|
|
14
|
-
const TTL_MS = 5 * 60 * 1000;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
29
|
+
const TTL_MS = 5 * 60 * 1000; // only for a folder with no signature
|
|
30
|
+
const MAX_AGE_MS = 24 * 3600 * 1000; // an entry this old is rebuilt even when the signature holds
|
|
31
|
+
const SIG_REUSE_MS = 2000; // a burst of requests asks git once
|
|
32
|
+
const memo = new Map(); // root -> { at, sig, engine, facts, ms }
|
|
33
|
+
const inflight = new Map(); // root -> Promise
|
|
34
|
+
const sigMemo = new Map(); // root -> { at, sig }
|
|
35
|
+
const lastError = new Map(); // root -> { at, error }
|
|
36
|
+
let stateDir = null;
|
|
37
|
+
let engineMemo = null;
|
|
38
|
+
|
|
39
|
+
const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
40
|
+
|
|
41
|
+
/** The version of the code that derives the facts: a change to it makes every entry stale. */
|
|
42
|
+
function engineVersion() {
|
|
43
|
+
if (engineMemo) return engineMemo;
|
|
44
|
+
const sig = path.resolve(__dirname, '..', '..', 'scripts');
|
|
45
|
+
const parts = [CLI, path.join(sig, 'signals', 'facts.js'), path.join(sig, 'workflow-state.js')].map((f) => {
|
|
46
|
+
try { return sha1(fs.readFileSync(f)); } catch { return '-'; }
|
|
47
|
+
});
|
|
48
|
+
engineMemo = sha1(parts.join('|')).slice(0, 12);
|
|
49
|
+
return engineMemo;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function configure(o = {}) { stateDir = o.stateDir || null; }
|
|
53
|
+
const fileOf = (root) => (stateDir ? path.join(stateDir, 'facts-cache', sha1(root).slice(0, 16) + '.json') : null);
|
|
54
|
+
|
|
55
|
+
function loadDisk(root) {
|
|
56
|
+
const f = fileOf(root);
|
|
57
|
+
if (!f) return null;
|
|
58
|
+
try {
|
|
59
|
+
const e = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
60
|
+
return e && e.root === root && e.facts && typeof e.at === 'number' ? e : null;
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
function saveDisk(root, entry) {
|
|
64
|
+
const f = fileOf(root);
|
|
65
|
+
if (!f) return;
|
|
66
|
+
try {
|
|
67
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
68
|
+
const tmp = `${f}.${process.pid}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify({ root, ...entry }));
|
|
70
|
+
fs.renameSync(tmp, f);
|
|
71
|
+
} catch { /* a cache that cannot be written is only a slower next start */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function currentSig(root, opts, clock) {
|
|
75
|
+
if (opts.signature) return opts.signature(root);
|
|
76
|
+
const hit = sigMemo.get(root);
|
|
77
|
+
if (hit && clock() - hit.at < SIG_REUSE_MS) return hit.sig;
|
|
78
|
+
let sig = null;
|
|
79
|
+
try {
|
|
80
|
+
// a folder with no HEAD is not a checkout: no signature, and the TTL rule decides
|
|
81
|
+
const head = await new Promise((resolve) => execFile('git', ['rev-parse', 'HEAD'], { cwd: root, timeout: 15000 }, (e, out) => resolve(e ? null : out)));
|
|
82
|
+
if (head != null) sig = await repoWatch.signature(root, async () => ({ files: [] }));
|
|
83
|
+
} catch { sig = null; }
|
|
84
|
+
sigMemo.set(root, { at: clock(), sig });
|
|
85
|
+
return sig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalize(facts, today) {
|
|
89
|
+
try { return require('../../scripts/signals/facts.js').normalizeFacts(facts, today || undefined); } catch { return facts; }
|
|
90
|
+
}
|
|
17
91
|
|
|
18
92
|
function readFactsAsync(root, opts = {}) {
|
|
19
|
-
const today = opts.today || '';
|
|
20
93
|
const resolved = path.resolve(root || process.cwd());
|
|
21
|
-
const key = resolved + '|' + today;
|
|
22
94
|
const clock = opts.clock || Date.now;
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
95
|
+
const today = opts.today || '';
|
|
96
|
+
const out = (facts) => (opts.run ? facts : normalize(facts, today));
|
|
97
|
+
return (async () => {
|
|
98
|
+
let hit = memo.get(resolved);
|
|
99
|
+
if (!hit) { const d = loadDisk(resolved); if (d) { hit = d; memo.set(resolved, d); } }
|
|
100
|
+
const sig = await currentSig(resolved, opts, clock);
|
|
101
|
+
const engine = opts.run ? 'test' : engineVersion();
|
|
102
|
+
|
|
103
|
+
const start = () => {
|
|
104
|
+
if (inflight.has(resolved)) return inflight.get(resolved);
|
|
105
|
+
const run = opts.run || ((r) => new Promise((resolve, reject) => {
|
|
106
|
+
execFile(process.execPath, [CLI, r], { maxBuffer: 512 * 1024 * 1024, timeout: 15 * 60 * 1000 }, (err, stdout) => {
|
|
107
|
+
if (err) return reject(err);
|
|
108
|
+
try { resolve(JSON.parse(stdout)); } catch (e) { reject(e); }
|
|
109
|
+
});
|
|
110
|
+
}));
|
|
111
|
+
const t0 = Date.now();
|
|
112
|
+
const p = Promise.resolve(run(resolved)).then((facts) => {
|
|
113
|
+
const entry = { at: clock(), sig, engine, facts, ms: Date.now() - t0 };
|
|
114
|
+
memo.set(resolved, entry);
|
|
115
|
+
lastError.delete(resolved);
|
|
116
|
+
saveDisk(resolved, entry);
|
|
117
|
+
return facts;
|
|
118
|
+
}, (e) => { lastError.set(resolved, { at: clock(), error: String((e && e.message) || e).slice(0, 200) }); throw e; })
|
|
119
|
+
.finally(() => { inflight.delete(resolved); });
|
|
120
|
+
inflight.set(resolved, p);
|
|
121
|
+
return p;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (hit) {
|
|
125
|
+
const age = clock() - hit.at;
|
|
126
|
+
const fresh = sig != null
|
|
127
|
+
? hit.sig === sig && hit.engine === engine && age < MAX_AGE_MS
|
|
128
|
+
: age < (opts.ttlMs == null ? TTL_MS : opts.ttlMs);
|
|
129
|
+
if (fresh) return out(hit.facts);
|
|
130
|
+
if (opts.stale !== false) { start().catch(() => {}); return out(hit.facts); }
|
|
131
|
+
}
|
|
132
|
+
return out(await start());
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** How old the facts of `root` are, for the freshness surface (R6). Reads nothing but memory and the disk entry. */
|
|
137
|
+
function status(root) {
|
|
138
|
+
const resolved = path.resolve(root);
|
|
139
|
+
const hit = memo.get(resolved) || loadDisk(resolved);
|
|
140
|
+
const err = lastError.get(resolved);
|
|
141
|
+
return {
|
|
142
|
+
as_of: hit ? new Date(hit.at).toISOString() : null,
|
|
143
|
+
building: inflight.has(resolved),
|
|
144
|
+
took_ms: hit && hit.ms != null ? hit.ms : null,
|
|
145
|
+
error: err ? err.error : null,
|
|
41
146
|
};
|
|
147
|
+
}
|
|
42
148
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
149
|
+
/** Start a rebuild now (a change the watcher saw, or the PM's Refresh). The signature is asked again first. */
|
|
150
|
+
function refresh(root, opts = {}) {
|
|
151
|
+
const resolved = path.resolve(root);
|
|
152
|
+
sigMemo.delete(resolved);
|
|
153
|
+
const hit = memo.get(resolved);
|
|
154
|
+
if (hit && opts.force) memo.set(resolved, { ...hit, sig: '(forced)' });
|
|
155
|
+
return readFactsAsync(resolved, opts).catch(() => null);
|
|
47
156
|
}
|
|
48
157
|
|
|
49
|
-
function resetFactsCache() { memo.clear(); inflight.clear(); }
|
|
158
|
+
function resetFactsCache() { memo.clear(); inflight.clear(); sigMemo.clear(); lastError.clear(); }
|
|
50
159
|
|
|
51
|
-
module.exports = { readFactsAsync, resetFactsCache, TTL_MS };
|
|
160
|
+
module.exports = { readFactsAsync, resetFactsCache, refresh, status, configure, engineVersion, TTL_MS, MAX_AGE_MS };
|
|
@@ -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 };
|
|
@@ -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
|
});
|
|
@@ -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 });
|
|
@@ -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 };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-sources.js — how old each source of a workspace is, and Refresh now (workspace-data-freshness R6, R7).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sources?ws=<id> → { ok, ws, version, sources: [row] } lib/freshness.js sources()
|
|
5
|
+
* POST /api/sources/refresh { ws, source } → { ok, source, started: 'rebuild'|'session', session? }
|
|
6
|
+
* POST /api/sources/watch { ws, on } → { ok, watch } the change detector on or off for this repository
|
|
7
|
+
*
|
|
8
|
+
* Both POSTs pass pm-guard requirePagePress: the PM's press from the page. An agent session cannot start a refresh or
|
|
9
|
+
* switch the detector off. A refresh of `repository` or `initiatives` rebuilds the facts in a child process; a refresh of
|
|
10
|
+
* an external source starts that source's skill as a session in the workspace, with the PM's own connections. The server
|
|
11
|
+
* itself calls nothing outside this machine.
|
|
12
|
+
*/
|
|
13
|
+
const guard = require('./pm-guard.js');
|
|
14
|
+
const freshness = require('./freshness.js');
|
|
15
|
+
|
|
16
|
+
function wsOf(ctx, id) {
|
|
17
|
+
const w = ctx.getWs(id || '');
|
|
18
|
+
return w && (!id || w.id === id) ? w : null;
|
|
19
|
+
}
|
|
20
|
+
const repoStateDir = (ctx, w) => { try { return ctx.stateDirFor ? ctx.stateDirFor(w) : null; } catch { return null; } };
|
|
21
|
+
|
|
22
|
+
async function handle(req, res, url, ctx) {
|
|
23
|
+
const p = url.pathname;
|
|
24
|
+
const F = ctx.freshness;
|
|
25
|
+
if (!F) return false;
|
|
26
|
+
if (p === '/api/sources') {
|
|
27
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
28
|
+
const w = wsOf(ctx, url.searchParams.get('ws'));
|
|
29
|
+
if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
|
|
30
|
+
const sources = F.sources({ id: w.id, root: w.root, repoStateDir: repoStateDir(ctx, w) });
|
|
31
|
+
ctx.json(res, { ok: true, ws: w.id, version: F.versions()[w.id] || 0, sources });
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (p !== '/api/sources/refresh' && p !== '/api/sources/watch') return false;
|
|
35
|
+
if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
|
|
36
|
+
const got = await ctx.readJson(req);
|
|
37
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'the body is not JSON' }, 400); return true; }
|
|
38
|
+
const body = got.body || {};
|
|
39
|
+
const g = guard.requirePagePress(req, body);
|
|
40
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
41
|
+
const w = wsOf(ctx, typeof body.ws === 'string' ? body.ws : '');
|
|
42
|
+
if (!w) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
|
|
43
|
+
|
|
44
|
+
if (p === '/api/sources/watch') {
|
|
45
|
+
if (typeof body.on !== 'boolean') { ctx.json(res, { ok: false, error: 'on must be true or false' }, 400); return true; }
|
|
46
|
+
if (!F.setEnabled(w.root, body.on)) { ctx.json(res, { ok: false, error: 'the setting could not be saved' }, 500); return true; }
|
|
47
|
+
ctx.json(res, { ok: true, watch: F.enabled(w.root) });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const src = freshness.SOURCES.find((s) => s.id === body.source);
|
|
52
|
+
if (!src) { ctx.json(res, { ok: false, error: 'source must be one of ' + freshness.SOURCES.map((s) => s.id).join(', ') }, 400); return true; }
|
|
53
|
+
if (!src.skill) {
|
|
54
|
+
ctx.factsRefresh(w.root);
|
|
55
|
+
ctx.json(res, { ok: true, source: src.id, started: 'rebuild' });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
const r = ctx.startSkill(w.id, src.skill, `${src.label} · refresh`);
|
|
59
|
+
if (!r.ok) { ctx.json(res, { ok: false, error: r.error }, r.status || 500); return true; }
|
|
60
|
+
ctx.json(res, { ok: true, source: src.id, started: 'session', session: r.session });
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { prefixes: ['/api/sources'], handle };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.132.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.132.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -44,6 +44,7 @@ import { mountTicker } from './shell-ticker.js';
|
|
|
44
44
|
import { mountBell, openBellList } from './shell-bell.js';
|
|
45
45
|
import { onBriefing, loadBriefing } from './shell-ticker.js';
|
|
46
46
|
import { registry, filterPalette, rowStep } from './briefing-model.js';
|
|
47
|
+
import { freshnessLine } from './freshness-line.js';
|
|
47
48
|
|
|
48
49
|
/* In demo mode each tab's deck asks for its own cards (the mockup draws a different deck per tab); real mode sends no scope. */
|
|
49
50
|
const sc = (name) => (DEMO ? name : undefined);
|
|
@@ -105,7 +106,9 @@ function header() {
|
|
|
105
106
|
mountCalPopupTrigger(S.tools); // the calendar button (before Search) and the key C (c3-calpop.js)
|
|
106
107
|
S.moreBtn = ibtn('More', 'more', { 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'data-c3pg': 'more', onclick: toggleMore });
|
|
107
108
|
S.tools.append(el('span', { class: 'c3pg-menuwrap' }, S.moreBtn));
|
|
108
|
-
|
|
109
|
+
/* workspace-data-freshness R9: how old the sources are, quiet when they are up to date (freshness-line.js) */
|
|
110
|
+
S.fresh = S.demo ? null : freshnessLine(S.ws);
|
|
111
|
+
return el('div', { class: 'c3top' }, el('div', null, S.ttl, S.sub, S.fresh), S.tools);
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/* the More menu (console3.html menuPop, line 1940): why each element is here, incident day, agent playbooks, phone preview; and ours: the theme and a way to
|
|
@@ -3499,6 +3499,39 @@
|
|
|
3499
3499
|
.fr-set__envrows { display: flex; flex-direction: column; gap: 6px; }
|
|
3500
3500
|
.fr-set__flags { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; min-width: 0; width: 100%; }
|
|
3501
3501
|
|
|
3502
|
+
/* workspace-data-freshness: the console header's freshness line and Settings › Data freshness (pm/freshness-line.js) */
|
|
3503
|
+
.fr-fresh {
|
|
3504
|
+
display: flex;
|
|
3505
|
+
flex-wrap: wrap;
|
|
3506
|
+
align-items: center;
|
|
3507
|
+
gap: 8px;
|
|
3508
|
+
font-size: 12px;
|
|
3509
|
+
color: var(--muted-foreground);
|
|
3510
|
+
}
|
|
3511
|
+
.fr-fresh__state--fresh { color: var(--foreground); }
|
|
3512
|
+
.fr-fresh__state--stale { color: var(--warning); }
|
|
3513
|
+
.fr-fresh__state--failed { color: var(--destructive); }
|
|
3514
|
+
.fr-fresh__state--never,
|
|
3515
|
+
.fr-fresh__state--building,
|
|
3516
|
+
.fr-fresh__state--off { color: var(--muted-foreground); }
|
|
3517
|
+
.fr-fresh-table__grid {
|
|
3518
|
+
width: 100%;
|
|
3519
|
+
border-collapse: collapse;
|
|
3520
|
+
font-size: 12px;
|
|
3521
|
+
}
|
|
3522
|
+
.fr-fresh-table__grid th {
|
|
3523
|
+
padding: 4px 8px;
|
|
3524
|
+
text-align: left;
|
|
3525
|
+
font-weight: 600;
|
|
3526
|
+
}
|
|
3527
|
+
.fr-fresh-table__grid td {
|
|
3528
|
+
padding: 4px 8px;
|
|
3529
|
+
vertical-align: top;
|
|
3530
|
+
}
|
|
3531
|
+
.fr-fresh-table__watch {
|
|
3532
|
+
margin-top: 8px;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3502
3535
|
/* -- layouts · .td- Today (Run 3, slice 3B) -- */
|
|
3503
3536
|
.td-board {
|
|
3504
3537
|
display: flex;
|
|
@@ -33,6 +33,7 @@ import { COPY } from './frame-copy.js';
|
|
|
33
33
|
import { askConfirm } from './dialogs.js';
|
|
34
34
|
import { icon } from './icons.js';
|
|
35
35
|
import { flagPicker, flagsLine, flagsMissingValue } from './flag-picker.js';
|
|
36
|
+
import { freshnessTable } from './freshness-line.js';
|
|
36
37
|
|
|
37
38
|
const P = COPY.page;
|
|
38
39
|
const roots = new WeakMap(); // host -> { root, sig }
|
|
@@ -542,6 +543,13 @@ function checkButton(scope) {
|
|
|
542
543
|
});
|
|
543
544
|
}
|
|
544
545
|
|
|
546
|
+
/* ── Data freshness (workspace-data-freshness R9): how old each source is, Refresh, and the change detector switch ── */
|
|
547
|
+
function freshnessSection(scope) {
|
|
548
|
+
const ws = wsOfRepo(scope === 'all' ? null : activeProduct());
|
|
549
|
+
if (!ws) return section('freshness', 'Data freshness', [empty({ text: 'Open a repository to see how old its data is.' })], null, 'refresh-cw');
|
|
550
|
+
return section('freshness', 'Data freshness', [freshnessTable(ws)], null, 'refresh-cw');
|
|
551
|
+
}
|
|
552
|
+
|
|
545
553
|
/* ── mount ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
546
554
|
|
|
547
555
|
function signature(scope) {
|
|
@@ -583,7 +591,8 @@ export function mountDestination(host, ctx) {
|
|
|
583
591
|
peopleSection(scope),
|
|
584
592
|
onlineSection(),
|
|
585
593
|
themeSection(),
|
|
586
|
-
updatesSection(scope)
|
|
594
|
+
updatesSection(scope),
|
|
595
|
+
freshnessSection(scope)),
|
|
587
596
|
]);
|
|
588
597
|
}
|
|
589
598
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/* pm/freshness-line.js — how old each source of the workspace is (workspace-data-freshness R9).
|
|
2
|
+
*
|
|
3
|
+
* freshnessLine(ws) → element the console header's short line: the sources that are old or never read, else
|
|
4
|
+
* "Up to date"; a Refresh button for the oldest one. Updates itself every 15 s.
|
|
5
|
+
* freshnessTable(ws) → element Settings › Data freshness: every source, when it was read, its state, its schedule,
|
|
6
|
+
* Refresh, and the repository's Watch for changes switch.
|
|
7
|
+
*
|
|
8
|
+
* Both read GET /api/sources (lib/routes-sources.js). The presses go through pmPost (the page nonce), so an agent cannot
|
|
9
|
+
* make them. When a workspace's data version moves, the line sends `pmos:data-changed` with { ws } on window, so a view
|
|
10
|
+
* that wants to read again only on a change can listen for it. One element per ws and kind, kept across redraws, so a
|
|
11
|
+
* page that rebuilds its DOM does not start a second poll.
|
|
12
|
+
*/
|
|
13
|
+
import { el, setChildren } from './dom.js';
|
|
14
|
+
import { pmGet, pmPost } from './http.js';
|
|
15
|
+
import { button, SEL } from './primitives.js';
|
|
16
|
+
|
|
17
|
+
const POLL_MS = 15000;
|
|
18
|
+
const live = new Map(); // kind|ws -> { node, timer, version }
|
|
19
|
+
|
|
20
|
+
const hhmm = (iso) => { const d = new Date(iso); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); };
|
|
21
|
+
function ago(iso) {
|
|
22
|
+
if (!iso) return 'never read';
|
|
23
|
+
const ms = Date.now() - Date.parse(iso);
|
|
24
|
+
if (ms < 60000) return 'just now';
|
|
25
|
+
if (ms < 3600000) return Math.round(ms / 60000) + ' min ago';
|
|
26
|
+
if (ms < 24 * 3600000) return 'today ' + hhmm(iso);
|
|
27
|
+
return Math.round(ms / (24 * 3600000)) + ' d ago';
|
|
28
|
+
}
|
|
29
|
+
const STATE_WORD = { fresh: 'Up to date', stale: 'Old', never: 'Never read', building: 'Reading…', failed: 'Failed', off: 'Off' };
|
|
30
|
+
// the colour of a state: a class in components.css (.fr-fresh__state--<state>), coloured from the tokens
|
|
31
|
+
const tone = (state) => 'fr-fresh__state fr-fresh__state--' + (STATE_WORD[state] ? state : 'never');
|
|
32
|
+
const label = (b, t) => { const l = b.querySelector(SEL.btnLabel); (l || b).textContent = t; };
|
|
33
|
+
|
|
34
|
+
async function read(ws) {
|
|
35
|
+
const r = await pmGet('/api/sources' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
|
|
36
|
+
return r && r.ok ? r : null;
|
|
37
|
+
}
|
|
38
|
+
async function refresh(ws, source, btn) {
|
|
39
|
+
if (btn) { btn.disabled = true; label(btn, 'Starting…'); }
|
|
40
|
+
const r = await pmPost('/api/sources/refresh', { ws, source });
|
|
41
|
+
if (btn) label(btn, r && r.ok ? (r.started === 'session' ? 'Agent started' : 'Reading…') : 'Could not start');
|
|
42
|
+
return r;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function poll(key, ws, draw) {
|
|
46
|
+
let cur = live.get(key);
|
|
47
|
+
if (cur) return cur.node;
|
|
48
|
+
cur = { node: null, timer: null, version: null };
|
|
49
|
+
live.set(key, cur);
|
|
50
|
+
const tick = async () => {
|
|
51
|
+
const d = await read(ws).catch(() => null);
|
|
52
|
+
if (d) {
|
|
53
|
+
if (cur.version != null && d.version !== cur.version) {
|
|
54
|
+
try { window.dispatchEvent(new CustomEvent('pmos:data-changed', { detail: { ws: d.ws } })); } catch { /* no window */ }
|
|
55
|
+
}
|
|
56
|
+
cur.version = d.version;
|
|
57
|
+
}
|
|
58
|
+
draw(cur.node, d);
|
|
59
|
+
};
|
|
60
|
+
cur.tick = tick;
|
|
61
|
+
cur.node = draw(null, null);
|
|
62
|
+
tick();
|
|
63
|
+
cur.timer = setInterval(() => { if (!cur.node.isConnected && cur.node.dataset.mounted === '1') { clearInterval(cur.timer); live.delete(key); return; } if (cur.node.isConnected) cur.node.dataset.mounted = '1'; tick(); }, POLL_MS);
|
|
64
|
+
return cur.node;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The header line: quiet when everything is fresh, amber names when a source is old or never read. */
|
|
68
|
+
export function freshnessLine(ws) {
|
|
69
|
+
return poll('line|' + ws, ws, (node, d) => {
|
|
70
|
+
const n = node || el('div', { class: 'fr-fresh', 'data-c3pg': 'freshness' });
|
|
71
|
+
if (!d) { if (!node) n.textContent = ''; return n; }
|
|
72
|
+
const watched = d.sources.filter((s) => s.state !== 'off');
|
|
73
|
+
const behind = watched.filter((s) => s.state === 'stale' || s.state === 'failed' || (s.state === 'never' && s.schedule));
|
|
74
|
+
const building = watched.find((s) => s.state === 'building');
|
|
75
|
+
const kids = [];
|
|
76
|
+
if (!behind.length) kids.push(el('span', { class: tone('fresh') }, building ? 'Reading the repository…' : 'Data up to date'));
|
|
77
|
+
for (const s of behind.slice(0, 3)) kids.push(el('span', { class: tone(s.state), title: STATE_WORD[s.state] }, s.label + ' ' + ago(s.as_of)));
|
|
78
|
+
if (behind.length) {
|
|
79
|
+
const first = behind[0];
|
|
80
|
+
const btn = button({ label: 'Refresh ' + first.label, size: 'sm', attrs: { 'data-c3pg': 'freshness-refresh' },
|
|
81
|
+
onClick: () => refresh(d.ws, first.id, btn) });
|
|
82
|
+
kids.push(btn);
|
|
83
|
+
}
|
|
84
|
+
setChildren(n, kids);
|
|
85
|
+
return n;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Settings table: every source of the workspace, with Refresh, and the change detector switch. */
|
|
90
|
+
export function freshnessTable(ws) {
|
|
91
|
+
return poll('table|' + ws, ws, (node, d) => {
|
|
92
|
+
const n = node || el('div', { class: 'fr-fresh-table', 'data-fr': 'freshness' });
|
|
93
|
+
if (!d) { if (!node) n.textContent = 'Reading…'; return n; }
|
|
94
|
+
const th = (t) => el('th', null, t);
|
|
95
|
+
const td = (...k) => el('td', null, ...k);
|
|
96
|
+
const rows = d.sources.map((s) => {
|
|
97
|
+
const btn = button({ label: 'Refresh', size: 'sm', disabled: s.state === 'off' || s.state === 'building',
|
|
98
|
+
attrs: { 'data-fr': 'freshness-refresh-' + s.id }, onClick: () => refresh(d.ws, s.id, btn) });
|
|
99
|
+
const sched = s.schedule ? [s.schedule.cadence, s.schedule.day, s.schedule.time].filter(Boolean).join(' ') : (s.skill ? 'no schedule' : 'on every change');
|
|
100
|
+
return el('tr', { 'data-fr-source': s.id },
|
|
101
|
+
td(s.label), td(s.id === 'repository' && s.state === 'fresh' ? 'watching' : s.as_of ? ago(s.as_of) : 'never'), td(el('span', { class: tone(s.state) }, STATE_WORD[s.state] || s.state)),
|
|
102
|
+
td(sched), td(btn));
|
|
103
|
+
});
|
|
104
|
+
const repo = d.sources.find((s) => s.id === 'repository');
|
|
105
|
+
const on = !repo || repo.state !== 'off';
|
|
106
|
+
// a button, not a checkbox: on a phone every control is 44px high (R27), and a checkbox box is not
|
|
107
|
+
const sw = button({ label: on ? 'Stop watching this repository' : 'Watch this repository for changes', size: 'sm',
|
|
108
|
+
attrs: { 'data-fr': 'freshness-watch', 'aria-pressed': on ? 'true' : 'false', class: 'fr-fresh-table__watch' },
|
|
109
|
+
onClick: async () => {
|
|
110
|
+
sw.disabled = true;
|
|
111
|
+
const r = await pmPost('/api/sources/watch', { ws: d.ws, on: !on });
|
|
112
|
+
if (r && r.ok) { const c = live.get('table|' + ws); if (c) c.tick(); } else { sw.disabled = false; label(sw, 'Could not save'); }
|
|
113
|
+
} });
|
|
114
|
+
setChildren(n, [
|
|
115
|
+
el('table', { class: 'fr-fresh-table__grid' },
|
|
116
|
+
el('thead', null, el('tr', null, th('Source'), th('Read'), th('State'), th('Schedule'), th(''))),
|
|
117
|
+
el('tbody', null, ...rows)),
|
|
118
|
+
sw,
|
|
119
|
+
el('p', { class: 'fr-set__note' }, 'The workspace holds no password or token. Calendar, Jira and My work are read by an agent session with your own connection.'),
|
|
120
|
+
]);
|
|
121
|
+
return n;
|
|
122
|
+
});
|
|
123
|
+
}
|
package/workspace/server.js
CHANGED
|
@@ -1942,6 +1942,21 @@ const autoSync = require('./lib/auto-sync').createAutoSync({
|
|
|
1942
1942
|
onDone: (root) => boardCache.invalidate(root),
|
|
1943
1943
|
label: `${os.hostname()} (auto-sync)`,
|
|
1944
1944
|
});
|
|
1945
|
+
// workspace-data-freshness (R2 to R4): the facts cache is kept in the runtime state folder, and ONE change detector per
|
|
1946
|
+
// workspace starts the board and facts rebuilds when the repository changes, so nothing is re-read on a clock. The board
|
|
1947
|
+
// keeps its last value while it rebuilds (stale while revalidate): dropping it would show a blank Board on every change.
|
|
1948
|
+
const factsAsync = require('./lib/facts-async.js');
|
|
1949
|
+
factsAsync.configure({ stateDir: STATE });
|
|
1950
|
+
const freshness = require('./lib/freshness.js').create({
|
|
1951
|
+
stateDir: STATE,
|
|
1952
|
+
workspaces: () => [...workspaces.values()],
|
|
1953
|
+
schedules: () => readSchedules(),
|
|
1954
|
+
factsStatus: (root) => factsAsync.status(root),
|
|
1955
|
+
onChange: (w) => { boardCache.board(w.root, { refreshNow: true }); factsAsync.refresh(w.root); },
|
|
1956
|
+
pollMs: Math.max(250, Number(process.env.PMOS_FRESH_POLL_MS) || 10000),
|
|
1957
|
+
stableMs: Math.max(0, Number(process.env.PMOS_FRESH_STABLE_MS) || 3000),
|
|
1958
|
+
});
|
|
1959
|
+
if (!REQUIRED_AS_LIBRARY) freshness.start();
|
|
1945
1960
|
const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
|
|
1946
1961
|
function readLifecycle(w) {
|
|
1947
1962
|
const latest = new Map();
|
|
@@ -2362,6 +2377,12 @@ const SEED_SETS = {
|
|
|
2362
2377
|
// jira-wbs (J14): the agent step that reads the status of every pushed Jira ticket into each initiative's snapshot,
|
|
2363
2378
|
// read-only. Daily at 06:30, so the morning brief (06:45) and every report read a status at most a day old.
|
|
2364
2379
|
jira: [{ name: 'Jira status check', skill: 'jira-sync', cadence: 'daily', time: '06:30' }],
|
|
2380
|
+
// workspace-data-freshness (R8): the read-only calendar pull, before the morning brief (06:45) and again at midday, so
|
|
2381
|
+
// Briefing and Calendar never work from yesterday's events.
|
|
2382
|
+
calendar: [
|
|
2383
|
+
{ name: 'Calendar pull (morning)', skill: 'calendar-sync', cadence: 'daily', time: '06:30' },
|
|
2384
|
+
{ name: 'Calendar pull (midday)', skill: 'calendar-sync', cadence: 'daily', time: '12:30' },
|
|
2385
|
+
],
|
|
2365
2386
|
};
|
|
2366
2387
|
function readSchedules() {
|
|
2367
2388
|
try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
|
|
@@ -4065,6 +4086,18 @@ function myWorkRead(wsId) {
|
|
|
4065
4086
|
if (!s || s.state === 'exited') return { ok: false, status: 500, error: (s && s.note) || 'the read session could not start' };
|
|
4066
4087
|
return { ok: true, session: s.id };
|
|
4067
4088
|
}
|
|
4089
|
+
/** Refresh now (workspace-data-freshness R7): start skill `skillId` in a session of repository `wsId`, as myWorkRead does. */
|
|
4090
|
+
function startSkillSession(wsId, skillId, name) {
|
|
4091
|
+
const w = getWs(wsId);
|
|
4092
|
+
if (!w) return { ok: false, status: 400, error: 'no workspace' };
|
|
4093
|
+
const task = taskById(skillId, w.root);
|
|
4094
|
+
if (!task || task.kind !== 'agent' || !task.prompt) return { ok: false, status: 409, error: `the ${skillId} skill is not installed for this repository` };
|
|
4095
|
+
const prompt = skillStore.retargetFiling(task.prompt, filingContract(w));
|
|
4096
|
+
const s = spawnSession({ name, agent: task.agent || 'claude', ws: w.id, origin: 'source-refresh', initialInput: prompt + '\r',
|
|
4097
|
+
modelAsk: { scheduleTier: null, skillTier: task.tier || null, skill: task.skill } });
|
|
4098
|
+
if (!s || s.state === 'exited') return { ok: false, status: 500, error: (s && s.note) || 'the refresh session could not start' };
|
|
4099
|
+
return { ok: true, session: s.id };
|
|
4100
|
+
}
|
|
4068
4101
|
/* The calendar the Agenda remembers (G23): one file beside the briefs, written only by the brief route. */
|
|
4069
4102
|
const CAL_MEMORY = () => path.join(STATE, 'calendar-memory.json');
|
|
4070
4103
|
// the PM's key-meeting marks (lib/calendar-memory.js setMark): a small file beside the calendar memory, written only by
|
|
@@ -6457,7 +6490,9 @@ async function handleRequest(req, res) {
|
|
|
6457
6490
|
// `ledgers` (pm-os-absorb Run 1) — the plan-items signature of every registered workspace, so an open Breakdown
|
|
6458
6491
|
// knows to refetch: `items` is one stat per workspace; `evidence` is computed only for the ids in ?ledgers=
|
|
6459
6492
|
// (at most 4). Computed on this request and never stored (D33).
|
|
6460
|
-
|
|
6493
|
+
// `data_versions` (workspace-data-freshness R4): a number per workspace that moves when its files change, so a page
|
|
6494
|
+
// refetches only then.
|
|
6495
|
+
if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION, data_versions: freshness.versions(),
|
|
6461
6496
|
ledgers: planItems.ledgerSigs([...workspaces.values()].map((w) => ({ id: w.id, root: w.root })), url.searchParams.get('ledgers')) });
|
|
6462
6497
|
if (p === '/api/git/branches') {
|
|
6463
6498
|
// Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
|
|
@@ -8654,7 +8689,9 @@ async function handleRequest(req, res) {
|
|
|
8654
8689
|
if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList, initiativeIndex,
|
|
8655
8690
|
runtimeDir: STATE, spawnSession, jevModelOpts, sendToSession,
|
|
8656
8691
|
// initiative my-work: every registered repository, the day runs, and the two presses that start sessions
|
|
8657
|
-
workspaceList: () => [...workspaces.values()], readDayRun, myWorkBrief, myWorkRead
|
|
8692
|
+
workspaceList: () => [...workspaces.values()], readDayRun, myWorkBrief, myWorkRead,
|
|
8693
|
+
// workspace-data-freshness: the source table, the facts rebuild and Refresh now's skill sessions
|
|
8694
|
+
freshness, factsRefresh: (root) => factsAsync.refresh(root, { force: true }), startSkill: startSkillSession })) return;
|
|
8658
8695
|
res.writeHead(404); res.end('not found');
|
|
8659
8696
|
}
|
|
8660
8697
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: calendar-sync
|
|
3
|
+
label: "Calendar pull"
|
|
4
|
+
description: "Read, read-only, the PM's calendar events for today and the next 14 days, and hand them to PMOS so Briefing and Calendar work from today's events. It writes nothing to the calendar."
|
|
5
|
+
run: agent
|
|
6
|
+
agent: claude
|
|
7
|
+
output:
|
|
8
|
+
- Imported
|
|
9
|
+
- Not reached
|
|
10
|
+
- Needs you
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
/calendar-sync — bring the PM's calendar up to date in PMOS. You read the calendar with the PM's own calendar connection. PMOS saves what you read and shows it on Briefing and Calendar. You change nothing in the calendar.
|
|
14
|
+
|
|
15
|
+
Rules that always hold:
|
|
16
|
+
1. READ-ONLY on the calendar. Use only the tools that list or read events. Never create, edit, move, accept, decline, delete or respond to an event, and never send an invitation or a message.
|
|
17
|
+
2. THE ONLY WRITE is `pmos calendar import --from <file> --sensitivity <label>`, once, at the end. Never edit a repository file. Never commit.
|
|
18
|
+
3. LIGHT ON CONTENT. For each event, give only its id, title, start, end, the attendees' display names and, when you can tell, the initiative id it is for. Never copy a description, a meeting link, a dial-in number, an attachment, an email address or a token.
|
|
19
|
+
4. SENSITIVITY. Use `internal` unless the PM said otherwise. Leave out any event marked private, personal, confidential or HR, and count it.
|
|
20
|
+
5. TEMPORARY FILES OUTSIDE THE REPOSITORY. Write the events file in this session's scratch folder, and delete it after the import.
|
|
21
|
+
6. NOTHING INVENTED. If the calendar cannot be reached, list it under "Not reached" and import nothing: an empty import is not a calendar with no meetings.
|
|
22
|
+
|
|
23
|
+
Procedure:
|
|
24
|
+
1. List the PM's events from the start of today to the end of the 14th day after today, from the primary calendar. Fetch every page. Skip an event the PM declined.
|
|
25
|
+
2. Write the events as JSON in the scratch folder, for example `<scratch>/calendar.json`, as a list with one object per event:
|
|
26
|
+
`{"id", "title", "start", "end", "attendees": ["Display Name", …], "initiative": "<initiative id or null>"}`
|
|
27
|
+
`start` and `end` are ISO times with the offset, for example `2026-10-12T09:00:00+07:00`. An all-day event has the date only.
|
|
28
|
+
3. Run `pmos calendar import --from <scratch>/calendar.json --sensitivity internal`. If it refuses the file, fix what it names and run it once more.
|
|
29
|
+
4. Delete the scratch file.
|
|
30
|
+
5. Report in four short lists: Imported (the count, and the first and last day), Left out (the count of private events), Not reached, and Needs you (only when something blocked the pull).
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "workspace-data-freshness: the shared commit range, the change-keyed facts cache, the change detector and data version, the source table and Refresh now, the worker-thread folds, and pmos calendar.",
|
|
3
|
+
"modules": ["data-freshness.test.js"],
|
|
4
|
+
"panels": [],
|
|
5
|
+
"seconds": { "data-freshness.test.js": 25 }
|
|
6
|
+
}
|