create-pmos 0.211.0 → 0.212.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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-settings-test.js +53 -2
- package/workspace/guide/data-freshness.md +35 -8
- package/workspace/guide/index.md +1 -1
- package/workspace/lib/driver-run.js +4 -3
- package/workspace/lib/freshness.js +2 -0
- package/workspace/lib/refresh-run.js +300 -0
- package/workspace/lib/refresh.js +464 -0
- package/workspace/lib/report-finder.js +22 -1
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-refresh.js +93 -0
- package/workspace/lib/settings-store.js +0 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-quiet.css +6 -1
- package/workspace/public/pm/c3-settings.css +11 -0
- package/workspace/public/pm/c3-settings.js +233 -9
- package/workspace/replaced-tests.json +11 -0
- package/workspace/server.js +101 -11
- package/workspace/test-lists/data-refresh-settings.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.212.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-14T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T02:50:31Z"}
|
|
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-14T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T02:50:31Z"}
|
|
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-14T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T02:50:31Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ const finish = async (code) => {
|
|
|
27
27
|
};
|
|
28
28
|
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
29
29
|
|
|
30
|
-
const SECTIONS = ['about', 'workspaces', 'boards', 'connectors', 'recording', 'time', 'sharing', 'access', 'sessions', 'privacy', 'appearance', 'updates'];
|
|
30
|
+
const SECTIONS = ['about', 'workspaces', 'boards', 'connectors', 'refresh', 'recording', 'time', 'sharing', 'access', 'sessions', 'privacy', 'appearance', 'updates'];
|
|
31
31
|
|
|
32
32
|
async function waitFor(page, fn, arg, ms = 8000) {
|
|
33
33
|
try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; }
|
|
@@ -66,7 +66,7 @@ async function waitFor(page, fn, arg, ms = 8000) {
|
|
|
66
66
|
/* ── B: every section draws ── */
|
|
67
67
|
await waitFor(page, () => !!document.querySelector('[data-c3set-section] .st-row'));
|
|
68
68
|
ok('B0 Settings opens on About you, drawn as console3 section cards', await waitFor(page, () => !!document.querySelector('[data-c3set-section="about"] .card.sect .list .nrow.st-row #c3set-you-name')));
|
|
69
|
-
ok('B1 the nav lists
|
|
69
|
+
ok('B1 the nav lists every section', (await page.$$eval('[data-c3set-nav] [data-c3set-sec]', (b) => b.length)) === SECTIONS.length);
|
|
70
70
|
for (const id of SECTIONS) {
|
|
71
71
|
await page.click(`[data-c3set-nav] [data-c3set-sec="${id}"]`);
|
|
72
72
|
const drew = await waitFor(page, (s) => { const x = document.querySelector('[data-c3set-section="' + s + '"]'); return x && x.querySelectorAll('.st-row, .sect, .fold').length > 0; }, id, 4000);
|
|
@@ -107,6 +107,57 @@ async function waitFor(page, fn, arg, ms = 8000) {
|
|
|
107
107
|
await page.click('[data-c3set-nav] [data-c3set-sec="access"]');
|
|
108
108
|
ok('D7 phone pairing shows, turned off, with the reason', await waitFor(page, () => { const r = document.querySelector('[data-c3set-row="access.pair"]'); return !!r && !!r.querySelector('button:disabled') && !!r.querySelector('.fold'); }));
|
|
109
109
|
|
|
110
|
+
|
|
111
|
+
/* ── R: Data refresh (data-refresh-settings R9) ── */
|
|
112
|
+
await page.goto(BASE + '/c3.html?tab=settings');
|
|
113
|
+
await waitFor(page, () => !!document.querySelector('[data-c3set-nav] [data-c3set-sec="connectors"]'));
|
|
114
|
+
await page.click('[data-c3set-nav] [data-c3set-sec="connectors"]');
|
|
115
|
+
ok('R1 Connectors links to Data refresh instead of holding its schedule', await waitFor(page, () => { const a = document.querySelector('[data-c3set-row="conn.cal.refresh"]'); const b = document.querySelector('[data-c3set-row="conn.jira.refresh"]'); return !!a && !!b && !!a.querySelector('button.lk') && /Data refresh/.test(a.textContent) && !a.querySelector('select') && !b.querySelector('select'); }));
|
|
116
|
+
await page.click('[data-c3set-row="conn.cal.refresh"] button.lk');
|
|
117
|
+
ok('R2 the link opens the Data refresh section', await waitFor(page, () => !!document.querySelector('[data-c3set-section="refresh"] [data-c3set-rf-card]')));
|
|
118
|
+
ok('R3 the section draws 13 source rows in three cards', await waitFor(page, () => document.querySelectorAll('[data-c3set-section="refresh"] [data-c3set-rf]').length === 13 && document.querySelectorAll('[data-c3set-rf-card]').length === 3));
|
|
119
|
+
ok('R3b the overview, the rules and the log are there', await page.evaluate(() => !!document.querySelector('[data-c3set-rf-sum]') && !!document.querySelector('[data-c3set-rf-all]') && !!document.querySelector('[data-c3set-rf-rules]') && !!document.querySelector('[data-c3set-rf-log]')));
|
|
120
|
+
ok('R3c a source with its own home says where, and has no editor button', await page.evaluate(() => { const r = document.querySelector('[data-c3set-rf="scan"]'); return !!r && /Set in Initiatives/.test(r.textContent) && !r.querySelector('[data-c3set-rf-open]'); }));
|
|
121
|
+
ok('R3d the filter Off lists the sources that are off', await (async () => { await page.click('[data-c3set-section="refresh"] .seg [data-v="off"]'); const n = await page.evaluate(() => document.querySelectorAll('[data-c3set-rf]').length); await page.click('[data-c3set-section="refresh"] .seg [data-v="all"]'); return n > 0 && n < 13; })());
|
|
122
|
+
|
|
123
|
+
// turning on needs no question
|
|
124
|
+
await page.click('[data-c3set-rf-on="calendar"]');
|
|
125
|
+
ok('R4 turning a source on posts on:true and shows a toast with Undo', await waitFor(page, () => { const t = document.querySelector('[data-c3-toast]'); return !!t && /Turned on: Calendar/.test(t.textContent) && !!t.querySelector('[data-c3-toast-action]'); }));
|
|
126
|
+
ok('R4b the server has it on', await waitFor(page, async () => { const j = await (await fetch('/api/refresh')).json(); return j.rows.find((x) => x.id === 'calendar').on === true; }));
|
|
127
|
+
|
|
128
|
+
// the schedule editor posts the schedule
|
|
129
|
+
await page.click('[data-c3set-rf-open="calendar"]');
|
|
130
|
+
ok('R5 Schedule opens the editor under the row', await waitFor(page, () => !!document.querySelector('[data-c3set-rf="calendar"] [data-c3set-rf-editor]')));
|
|
131
|
+
await page.selectOption('#c3set-rf-time-0', '07:00');
|
|
132
|
+
const posted = page.waitForRequest((q) => q.url().endsWith('/api/refresh/source') && q.method() === 'POST' && /schedule/.test(q.postData() || ''), { timeout: 8000 }).catch(() => null);
|
|
133
|
+
await page.click('[data-c3set-rf-save="calendar"]');
|
|
134
|
+
const req = await posted; const body = req ? JSON.parse(req.postData()) : null;
|
|
135
|
+
ok('R6 Save posts {id, schedule} with the new time', body && body.id === 'calendar' && body.schedule.kind === 'daily' && body.schedule.times[0] === '07:00', JSON.stringify(body));
|
|
136
|
+
ok('R6b the save says so in a toast with Undo', await waitFor(page, () => { const t = document.querySelector('[data-c3-toast]'); return !!t && /Saved: Calendar/.test(t.textContent) && /07:00/.test(t.textContent) && !!t.querySelector('[data-c3-toast-action]'); }));
|
|
137
|
+
ok('R6c the row shows the new schedule', await waitFor(page, () => /07:00/.test((document.querySelector('[data-c3set-rf="calendar"] .chips') || {}).textContent || '')));
|
|
138
|
+
|
|
139
|
+
// turning a source off asks first and names what it affects
|
|
140
|
+
await page.click('[data-c3set-rf-on="jira"]');
|
|
141
|
+
await waitFor(page, async () => (await (await fetch('/api/refresh')).json()).rows.find((x) => x.id === 'jira').on === true);
|
|
142
|
+
await page.click('[data-c3set-rf-on="jira"]');
|
|
143
|
+
ok('R7 turning off asks first and names the impact and what uses it', await waitFor(page, () => { const a = document.querySelector('[data-c3set-rf-ask="jira"]'); return !!a && /Sprint summaries stop/.test(a.textContent) && /Core documents/.test(a.textContent) && /Initiative driver/.test(a.textContent); }));
|
|
144
|
+
ok('R7b nothing changed yet', await page.evaluate(async () => (await (await fetch('/api/refresh')).json()).rows.find((x) => x.id === 'jira').on === true));
|
|
145
|
+
await page.click('[data-c3set-rf-off-go="jira"]');
|
|
146
|
+
ok('R7c confirming posts off and shows a toast with Undo', await waitFor(page, async () => { const j = await (await fetch('/api/refresh')).json(); const t = document.querySelector('[data-c3-toast]'); return j.rows.find((x) => x.id === 'jira').on === false && !!t && /Turned off: Jira/.test(t.textContent) && !!t.querySelector('[data-c3-toast-action]'); }));
|
|
147
|
+
|
|
148
|
+
// the rules
|
|
149
|
+
await page.selectOption('#c3set-rf-max', '2');
|
|
150
|
+
ok('R8 a rule is saved through POST /api/refresh/rules', await waitFor(page, async () => (await (await fetch('/api/refresh')).json()).rules.max === '2'));
|
|
151
|
+
ok('R8b the rules card shows quiet and working hours read-only', await page.evaluate(() => { const r = document.querySelector('[data-c3set-row="rf.quiet"]'); const w = document.querySelector('[data-c3set-row="rf.window"]'); return !!r && !!w && !r.querySelector('select, input') && /09:00 to 18:00/.test(w.textContent); }));
|
|
152
|
+
|
|
153
|
+
// the log
|
|
154
|
+
const ran = await page.evaluate(async () => { const { pmPost } = await import('/pm/http.js'); const w = (await (await fetch('/api/refresh')).json()).ws; return pmPost('/api/refresh/run', { id: 'signals', ws: w }); });
|
|
155
|
+
ok('R9 a refresh can be pressed', ran && ran.ok, JSON.stringify(ran));
|
|
156
|
+
ok('R9b the log view lists the run, newest first, with Export', await waitFor(page, () => { const l = document.querySelector('[data-c3set-rf-log]'); return !!l && l.querySelectorAll('[data-c3set-rfrun]').length > 0 && /Signals/.test(l.textContent) && /\/api\/refresh\/log\.csv/.test((l.querySelector('[data-c3set-rf-export]') || {}).href || ''); }, null, 20000));
|
|
157
|
+
await page.selectOption('#c3set-rf-log-result', 'failed');
|
|
158
|
+
ok('R9c filtering by result puts it in the Export link', await waitFor(page, () => /result=failed/.test((document.querySelector('[data-c3set-rf-export]') || {}).href || '')));
|
|
159
|
+
await page.selectOption('#c3set-rf-log-result', '');
|
|
160
|
+
|
|
110
161
|
/* ── E: theme ── */
|
|
111
162
|
await page.click('[data-c3set-nav] [data-c3set-sec="appearance"]');
|
|
112
163
|
await waitFor(page, () => !!document.querySelector('[data-c3set-row="look.theme"] [data-v="dark"]'));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Data freshness: how the workspace keeps up to date, and how old each source is"
|
|
4
|
-
timestamp: 2026-10-
|
|
4
|
+
timestamp: 2026-10-14
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Data freshness
|
|
@@ -15,18 +15,18 @@ The workspace checks each registered repository every 10 seconds for a change: a
|
|
|
15
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
16
|
- **Board and My work** are folded in a separate thread for the same reason.
|
|
17
17
|
|
|
18
|
-
To stop the checks
|
|
18
|
+
To stop the checks, turn off **Repository and initiatives** in Settings › Data refresh. This stops them for every registered repository. The pages then show what was read last, and **Refresh now** reads it again. Turn it on to start the checks again.
|
|
19
19
|
|
|
20
20
|
## Jira, calendar, My work and tracker items
|
|
21
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
|
|
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 the schedule set in **Settings › Data refresh**, or when you press **Refresh**:
|
|
23
23
|
|
|
24
24
|
| Source | Skill | Default schedule | Shown as old after |
|
|
25
25
|
|---|---|---|---|
|
|
26
|
-
| Calendar | calendar-sync | 06:30 and 12:30 every day
|
|
27
|
-
| Jira status | jira-sync | 06:30 every day
|
|
28
|
-
| My work | my-work-read | when you press
|
|
29
|
-
| Tracker items | tracker-refresh | Friday 15:30
|
|
26
|
+
| Calendar | calendar-sync | 06:30 and 12:30 every day | 8 hours |
|
|
27
|
+
| Jira status | jira-sync | 06:30 every day | 26 hours |
|
|
28
|
+
| My work | my-work-read | when you press Refresh | 26 hours |
|
|
29
|
+
| Tracker items | tracker-refresh | Friday 15:30 | 8 days |
|
|
30
30
|
|
|
31
31
|
The calendar-sync skill reads today and the next 14 days, read-only, and saves the events with this command:
|
|
32
32
|
|
|
@@ -52,4 +52,31 @@ Each page shows a short line that says when each source was last read, for examp
|
|
|
52
52
|
- A watched repository is not named, because the workspace checks it every few seconds.
|
|
53
53
|
- The refresh icon at the end of the line reads every source again: the initiatives on this machine, and an agent read for Calendar, Jira status, My work and tracker items. A source that is switched off or is being read already is left alone. A short note says how many reads started, or which could not start.
|
|
54
54
|
|
|
55
|
-
Settings › Data
|
|
55
|
+
## Settings › Data refresh
|
|
56
|
+
|
|
57
|
+
**Settings › Data refresh** is the one place that says what PMOS refreshes and when. It lists 13 sources in three groups: from your tools (Calendar, Jira, work assigned to you, tracker files), written by PMOS (the morning briefing, the day check, the horizon scan, core documents, the initiative driver, the report finder, signals) and on change (the repository and Git sync).
|
|
58
|
+
|
|
59
|
+
For each source you can:
|
|
60
|
+
|
|
61
|
+
- Turn it on or off. Before a source goes off, PMOS lists what it affects, for example "The initiative driver cannot see ticket moves".
|
|
62
|
+
- Set its schedule (the tool sources and the briefing): only when you press Refresh, every 15 minutes to 4 hours, daily or on working days at up to four times, or weekly. The day check runs every 30 or 60 minutes. The horizon scan and the report finder keep their schedules where they are set today, and the row says where.
|
|
63
|
+
- Press **Refresh now**. A source whose inputs are old offers **Refresh inputs first**.
|
|
64
|
+
- See when it last refreshed, the result, what it got or the error, the next run, what it uses and what uses it.
|
|
65
|
+
|
|
66
|
+
The schedules that the recommended sets made in schedules.json for these five skills move here once, on the first start. Each old entry is switched off with the reason "Moved to Settings › Data refresh". A recommended set or **Prepare my day** now turns the source on here instead of adding a schedule.
|
|
67
|
+
|
|
68
|
+
### The order of refreshes
|
|
69
|
+
|
|
70
|
+
A refresh waits for the sources it uses. The briefing waits for work assigned to you; core documents wait for the repository, Jira and the horizon scan; the driver waits for the repository, Jira and core documents. While an input is running or due, the refresh waits for up to 30 minutes, then it runs anyway and its log line says which input it did not wait for. An input that failed or is off does not hold anything: the refresh uses the last good data, and the row shows it under "Built on old data".
|
|
71
|
+
|
|
72
|
+
### The rules
|
|
73
|
+
|
|
74
|
+
- **Catch up**: a refresh missed while PMOS was closed runs once when PMOS starts. Off, the missed slot is skipped and written to the log.
|
|
75
|
+
- **Retry**: a failed scheduled refresh is tried once more after 15 or 60 minutes.
|
|
76
|
+
- **Tell me**: a failed scheduled refresh adds one item to Needs you on the Briefing.
|
|
77
|
+
- **Runs at once**: at most this many scheduled refreshes run together. A refresh you press is never held.
|
|
78
|
+
- No scheduled refresh starts in your quiet hours; the background jobs keep to the day check's working hours.
|
|
79
|
+
|
|
80
|
+
### The refresh log
|
|
81
|
+
|
|
82
|
+
Every background run is written to `refresh-log.jsonl` in your runtime folder on this computer: a start line and an end line with the result, the time it took, what it got and the error. A skipped slot writes a line with the reason. The start line records each input's state and when it last refreshed. A done refresh of Jira or of work assigned to you keeps a copy of what it read, so you can see what PMOS knew at any time. Lines and copies older than the keep setting (30, 90 or 365 days, or forever; 90 by default) are removed once a day. The log view filters by source and result, and **Export** downloads it as CSV.
|
package/workspace/guide/index.md
CHANGED
|
@@ -44,7 +44,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
44
44
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
45
45
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
|
46
46
|
- [My work](my-work.md): the work assigned to you, its order, the brief to an agent, and `pmos my-work`.
|
|
47
|
-
- [Data freshness](data-freshness.md): when each source is read again, how old it is, and `pmos calendar`.
|
|
47
|
+
- [Data freshness](data-freshness.md): when each source is read again, how old it is, Settings › Data refresh and its log, and `pmos calendar`.
|
|
48
48
|
- [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
|
|
49
49
|
- [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
|
|
50
50
|
- [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.
|
|
@@ -128,7 +128,8 @@ function create(deps) {
|
|
|
128
128
|
} catch (e) { warn('driver run end', e); }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
/* `sweepNow` (Refresh now, data-refresh-settings R8) runs the unblock sweep at once, inside or outside the window. */
|
|
132
|
+
function tick({ now = Date.now(), env, sweepNow = false } = {}) {
|
|
132
133
|
if (off(env || deps.env)) return { off: true };
|
|
133
134
|
const summary = { epoch: 0, woke: 0, swept: 0, started: 0, refused: 0 };
|
|
134
135
|
const leads = [];
|
|
@@ -155,9 +156,9 @@ function create(deps) {
|
|
|
155
156
|
for (const l of newWakes) { D.append(file, l, { now }); summary.woke++; }
|
|
156
157
|
state = readState(file);
|
|
157
158
|
// The unblock sweep: deterministic, every 30 minutes inside the day-check window.
|
|
158
|
-
const due = !lastSweep.has(file) || now - lastSweep.get(file) >= D.SWEEP_EVERY_MS;
|
|
159
|
+
const due = sweepNow || !lastSweep.has(file) || now - lastSweep.get(file) >= D.SWEEP_EVERY_MS;
|
|
159
160
|
let factsNow = null;
|
|
160
|
-
if (due && dayCheckOpen(settings, now, deps.inQuiet)) {
|
|
161
|
+
if (due && (sweepNow || dayCheckOpen(settings, now, deps.inQuiet))) {
|
|
161
162
|
const g = gather(w, deps.ctx, { now, includeWork: w.id === home, runtimeDir: deps.runtimeDir, driverState: state });
|
|
162
163
|
if (g.ok) {
|
|
163
164
|
factsNow = g.facts;
|
|
@@ -112,6 +112,8 @@ function create(o) {
|
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
function scheduleOf(skill) {
|
|
115
|
+
// data-refresh-settings: a skill source's schedule lives in Settings › Data refresh, which answers for it here.
|
|
116
|
+
if (skill && o.scheduleOf) { try { const r = o.scheduleOf(skill); if (r !== undefined) return r; } catch { /* fall back to schedules.json */ } }
|
|
115
117
|
if (!skill || !o.schedules) return null;
|
|
116
118
|
let list = [];
|
|
117
119
|
try { list = o.schedules() || []; } catch { return null; }
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* refresh-run.js — the refresh engine bound to a running server (initiative data-refresh-settings, PRD R3 to R8, R10).
|
|
3
|
+
*
|
|
4
|
+
* const run = create(host)
|
|
5
|
+
* run.tick(now) each minute: start the skill sources that are due, write skip lines, end lost runs, prune once a day
|
|
6
|
+
* run.started(session) · run.ended(session) every background session passes here; one with a refresh origin is logged (start, end)
|
|
7
|
+
* run.briefDone(got) a morning brief arrived: its run is done (the brief session stays open after it submits)
|
|
8
|
+
* run.codeRun(id, { result, got, error, took_ms, trigger }) one line pair for a run with no session (signals, repository, Git sync)
|
|
9
|
+
* run.jobGate(id, now) may job `id` run its tick now (off, or an input still running)
|
|
10
|
+
* run.runNow(id, { ws, withInputs }) Refresh now; with inputs, the old ones first and this one once they end
|
|
11
|
+
* run.rows(ws) · run.setSource(id, patch) · run.setRules(patch) · run.ensureOn(id, ws) · run.settings() · run.log()
|
|
12
|
+
*
|
|
13
|
+
* lib/refresh.js decides; this file holds the state between minutes and calls the host. The host is server.js:
|
|
14
|
+
* { stateDir, now?, homeWs(), proactive() → { quiet, dayCheck: { start, end, every } }, schedules: { read, write },
|
|
15
|
+
* startSkill(wsId, skill, name, origin) → { ok, session?, error? }, jobs: { [id]: (ws) → { ok, error? } },
|
|
16
|
+
* freshRows(ws) → [freshness row], copyPaths(id, ws) → [path], tell({ source, label, error, ws }), sessionAlive(id) → bool,
|
|
17
|
+
* setDayCheckEvery(every), setRepoWatch(on), warn(what, e) }
|
|
18
|
+
* Nothing here calls out of this machine: a skill source's run is the PM's own agent session, with the PM's connections.
|
|
19
|
+
*/
|
|
20
|
+
const R = require('./refresh.js');
|
|
21
|
+
|
|
22
|
+
const PRUNE_EVERY_MS = 24 * 3600 * 1000;
|
|
23
|
+
const LOST_MS = 6 * 3600 * 1000; // a run with no live session and no end after this long is written as interrupted
|
|
24
|
+
|
|
25
|
+
function create(host) {
|
|
26
|
+
const now = () => (host.now ? host.now() : Date.now());
|
|
27
|
+
const dir = host.stateDir;
|
|
28
|
+
const warn = (what, e) => { try { if (host.warn) host.warn(what, e); } catch { /* nothing to tell */ } };
|
|
29
|
+
const expect = new Map(); // source id → { trigger, note, inputs } for the start the engine itself is making now
|
|
30
|
+
const jobWait = new Map(); // job id → ms it started waiting for an input
|
|
31
|
+
let lastPrune = 0;
|
|
32
|
+
|
|
33
|
+
/* ── settings, with the one-time move of the seed schedules (R3) ── */
|
|
34
|
+
function settings() {
|
|
35
|
+
let s = R.readSettings(dir);
|
|
36
|
+
if (!s.migrated) {
|
|
37
|
+
try {
|
|
38
|
+
const m = R.migrate(s, host.schedules.read(), { homeWs: host.homeWs() });
|
|
39
|
+
if (m.adopted.length) host.schedules.write(m.schedules);
|
|
40
|
+
R.writeSettings(dir, m.settings);
|
|
41
|
+
s = R.withDefaults(m.settings);
|
|
42
|
+
} catch (e) { warn('refresh settings (moving the schedules)', e); }
|
|
43
|
+
}
|
|
44
|
+
// The day check's minutes have one home: the proactive settings.
|
|
45
|
+
const every = String(((host.proactive() || {}).dayCheck || {}).every || '60');
|
|
46
|
+
s.sources.daycheck.s = { kind: 'every', every: ['30', '60'].includes(every) ? every : '60', work: true };
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
const win = () => { const d = (host.proactive() || {}).dayCheck || {}; return { start: d.start || '08:00', end: d.end || '18:00' }; };
|
|
50
|
+
const quiet = () => { const q = (host.proactive() || {}).quiet; return q && q.start && q.end ? q : null; };
|
|
51
|
+
const log = () => R.foldLog(R.readLog(dir));
|
|
52
|
+
|
|
53
|
+
/* ── the state of each source between minutes ── */
|
|
54
|
+
const openOf = (st) => (st && st.open && typeof st.open === 'object' ? st.open : {});
|
|
55
|
+
const running = (st) => Object.keys(openOf(st)).length > 0;
|
|
56
|
+
function inputStateFn(set, state, t) {
|
|
57
|
+
return (id) => {
|
|
58
|
+
const x = set.sources[id]; const st = state[id] || {};
|
|
59
|
+
if (!x || !x.on) return 'off';
|
|
60
|
+
if (running(st)) return 'running';
|
|
61
|
+
if (st.waiting_since || st.pending) return 'waiting';
|
|
62
|
+
const src = R.BY_ID.get(id);
|
|
63
|
+
if (src && src.run === 'skill') {
|
|
64
|
+
const slot = R.lastSlot(x.s, t, win());
|
|
65
|
+
if (slot != null && (st.last_slot == null || slot > st.last_slot) && t - slot < 2 * 3600 * 1000) return 'due';
|
|
66
|
+
}
|
|
67
|
+
return 'ok';
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const runningCount = (state) => Object.values(state).reduce((n, st) => n + Object.keys(openOf(st)).length, 0);
|
|
71
|
+
function snapshot(id, set, state, t) {
|
|
72
|
+
const src = R.BY_ID.get(id); if (!src || !src.uses.length) return [];
|
|
73
|
+
const f = log(); const is = inputStateFn(set, state, t);
|
|
74
|
+
return src.uses.map((u) => { const l = f.lastRun(u); return { id: u, state: is(u), at: l ? (l.end_at || l.start_at) : null, result: l ? l.result : null }; });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ── a session started or ended ── */
|
|
78
|
+
const skillOfSchedule = (id) => { try { const x = host.schedules.read().find((y) => y.id === id); return x ? x.skill : null; } catch { return null; } };
|
|
79
|
+
function started(s) {
|
|
80
|
+
if (!s) return;
|
|
81
|
+
const id = R.sourceOfOrigin(s.origin, { skillOfSchedule });
|
|
82
|
+
if (!id) return;
|
|
83
|
+
const t = now();
|
|
84
|
+
const set = settings(); const state = R.readState(dir);
|
|
85
|
+
const ex = expect.get(id) || {}; expect.delete(id);
|
|
86
|
+
const st = state[id] || {};
|
|
87
|
+
const run = R.newRun(id, t);
|
|
88
|
+
const trigger = ex.trigger || (String(s.origin).startsWith('schedule:') ? 'schedule' : String(s.origin).startsWith('refresh:') ? 'pressed' : 'schedule');
|
|
89
|
+
const inputs = snapshot(id, set, state, t);
|
|
90
|
+
state[id] = { ...st, open: { ...openOf(st), [s.id]: { run, at: t, trigger } }, started_at: t, trigger, waiting_since: null, waiting_for: [], pending: null };
|
|
91
|
+
if (ex.slot != null) state[id].last_slot = ex.slot;
|
|
92
|
+
R.writeState(dir, state);
|
|
93
|
+
R.append(dir, { source: id, ws: s.ws || null, ev: 'start', run, trigger, session: s.id, inputs, ...(ex.note ? { note: ex.note } : {}) }, t);
|
|
94
|
+
if (s.state === 'exited') ended(s);
|
|
95
|
+
}
|
|
96
|
+
/** End the open run of session `s`. `over` sets the result for a run whose session has no outcome of its own. */
|
|
97
|
+
function ended(s, over) {
|
|
98
|
+
if (!s) return;
|
|
99
|
+
const state = R.readState(dir);
|
|
100
|
+
const id = Object.keys(state).find((k) => openOf(state[k])[s.id]);
|
|
101
|
+
if (!id) return;
|
|
102
|
+
const t = now();
|
|
103
|
+
const st = state[id]; const o = openOf(st)[s.id];
|
|
104
|
+
let result; let got = null; let error = null;
|
|
105
|
+
if (over) { result = over.result; got = over.got || null; error = over.error || null; }
|
|
106
|
+
else if (s.outcome) { result = s.outcome.ok ? 'done' : /Stopped by you/.test(s.outcome.why || '') ? 'interrupted' : 'failed'; got = s.outcome.headline || null; error = s.outcome.ok ? null : s.outcome.why; }
|
|
107
|
+
else if (id === 'brief') { result = 'failed'; error = 'The session ended without submitting a brief' + (s.note ? ` (${s.note})` : '') + '.'; }
|
|
108
|
+
else { result = /code 0\)$/.test(s.note || '') ? 'done' : 'failed'; error = result === 'failed' ? s.note || 'the session ended' : null; }
|
|
109
|
+
const rest = { ...openOf(st) }; delete rest[s.id];
|
|
110
|
+
const rules = settings().rules;
|
|
111
|
+
let copy = null;
|
|
112
|
+
const src = R.BY_ID.get(id);
|
|
113
|
+
if (result === 'done' && src && src.copy) { try { copy = R.keepCopy(dir, id, host.copyPaths(id, s.ws), t); } catch (e) { warn('refresh copy', e); } }
|
|
114
|
+
const next = { ...st, open: rest, last_end: t, last_result: result };
|
|
115
|
+
if (result === 'done') { next.retry_at = null; next.retried = false; }
|
|
116
|
+
const scheduled = ['schedule', 'catch-up', 'retry'].includes(o.trigger);
|
|
117
|
+
if (result === 'failed' && scheduled && src && src.run === 'skill' && rules.retry !== 'no' && !st.retried) { next.retry_at = t + Number(rules.retry) * 60000; next.retried = true; }
|
|
118
|
+
else if (result === 'failed' && o.trigger === 'retry') next.retry_at = null;
|
|
119
|
+
state[id] = next;
|
|
120
|
+
R.writeState(dir, state);
|
|
121
|
+
R.append(dir, { source: id, ws: s.ws || null, ev: 'end', run: o.run, result, took_ms: t - o.at, got, error, ...(copy ? { copy } : {}) }, t);
|
|
122
|
+
if (result === 'failed' && scheduled && rules.tell === 'brief' && !next.retry_at) {
|
|
123
|
+
try { host.tell({ source: id, label: src ? src.label : id, error: error || 'it failed', ws: s.ws || null }); } catch (e) { warn('refresh failure note', e); }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The morning brief arrived: the brief run that is open is done. */
|
|
127
|
+
function briefDone(got) {
|
|
128
|
+
const state = R.readState(dir); const st = state.brief;
|
|
129
|
+
for (const sid of Object.keys(openOf(st))) ended({ id: sid, ws: null }, { result: 'done', got: got || 'Briefing saved' });
|
|
130
|
+
}
|
|
131
|
+
/** A run with no session: one start line and one end line (signals, the repository rebuild, Git sync). */
|
|
132
|
+
function codeRun(id, { result = 'done', got = null, error = null, took_ms = null, trigger = 'change', ws = null } = {}) {
|
|
133
|
+
if (!R.BY_ID.has(id)) return;
|
|
134
|
+
const t = now(); const run = R.newRun(id, t);
|
|
135
|
+
const ex = expect.get(id); if (ex) { trigger = ex.trigger || trigger; expect.delete(id); }
|
|
136
|
+
R.append(dir, { source: id, ws, ev: 'start', run, trigger }, took_ms != null ? t - took_ms : t);
|
|
137
|
+
R.append(dir, { source: id, ws, ev: 'end', run, result, took_ms, got, error }, t);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ── the tick ── */
|
|
141
|
+
function tick(t = now()) {
|
|
142
|
+
const set = settings(); const state = R.readState(dir); const rules = set.rules;
|
|
143
|
+
let changed = false;
|
|
144
|
+
// A run whose session is gone with no end (the server restarted under it) is written as interrupted.
|
|
145
|
+
for (const [id, st] of Object.entries(state)) {
|
|
146
|
+
for (const [sid, o] of Object.entries(openOf(st))) {
|
|
147
|
+
let alive = true; try { alive = host.sessionAlive(sid); } catch { alive = true; }
|
|
148
|
+
if (!alive || t - o.at > LOST_MS) {
|
|
149
|
+
delete st.open[sid]; st.retry_at = null; changed = true;
|
|
150
|
+
R.append(dir, { source: id, ev: 'end', run: o.run, result: 'interrupted', took_ms: t - o.at, error: alive ? 'no end after 6 hours' : 'the session was gone (PMOS restarted, or the session was closed)' }, t);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (changed) R.writeState(dir, state);
|
|
155
|
+
const first = !tick.seen; tick.seen = true;
|
|
156
|
+
for (const src of R.SOURCES.filter((x) => x.run === 'skill')) {
|
|
157
|
+
const cur = R.readState(dir);
|
|
158
|
+
const st = { ...(cur[src.id] || {}), running: running(cur[src.id]) };
|
|
159
|
+
const d = R.decide(src, st, { now: t, settings: set, rules, win: win(), quiet: quiet(), runningCount: runningCount(cur), inputState: inputStateFn(set, cur, t), first });
|
|
160
|
+
if (d.act === 'none') continue;
|
|
161
|
+
const ws = (set.sources[src.id] && set.sources[src.id].ws) || (st.pending && st.pending.ws) || host.homeWs();
|
|
162
|
+
if (d.act === 'skip') {
|
|
163
|
+
cur[src.id] = { ...(cur[src.id] || {}), last_slot: d.slot };
|
|
164
|
+
R.writeState(dir, cur);
|
|
165
|
+
if (!d.quiet) R.append(dir, { source: src.id, ws, ev: 'skip', trigger: 'schedule', note: d.why }, t);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (d.act === 'wait') {
|
|
169
|
+
const x = cur[src.id] || {};
|
|
170
|
+
if (!x.waiting_since || JSON.stringify(x.waiting_for || []) !== JSON.stringify(d.waiting || [])) {
|
|
171
|
+
cur[src.id] = { ...x, waiting_since: x.waiting_since || t, waiting_for: d.waiting || [], waiting_why: d.why || null };
|
|
172
|
+
R.writeState(dir, cur);
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
start(src, ws, { trigger: d.trigger, slot: d.slot, note: d.note });
|
|
177
|
+
}
|
|
178
|
+
if (t - lastPrune > PRUNE_EVERY_MS) {
|
|
179
|
+
lastPrune = t;
|
|
180
|
+
try { const p = R.prune(dir, rules.keep, t); if (p.lines || p.copies) warn('refresh log', { message: `removed ${p.lines} lines and ${p.copies} copies older than ${rules.keep} days` }); } catch (e) { warn('refresh log prune', e); }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function start(src, ws, { trigger, slot, note }) {
|
|
184
|
+
expect.set(src.id, { trigger, slot, note });
|
|
185
|
+
let r;
|
|
186
|
+
try { r = host.startSkill(ws, src.skill, `${src.label} · refresh`, 'refresh:' + src.id); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
|
|
187
|
+
if (expect.has(src.id)) {
|
|
188
|
+
// Nothing started (no session was made): write it as a failed run so it shows, and keep the slot so it is not tried every minute.
|
|
189
|
+
expect.delete(src.id);
|
|
190
|
+
const t = now(); const run = R.newRun(src.id, t); const cur = R.readState(dir);
|
|
191
|
+
cur[src.id] = { ...(cur[src.id] || {}), last_slot: slot != null ? slot : (cur[src.id] || {}).last_slot, waiting_since: null, waiting_for: [], pending: null, last_result: 'failed' };
|
|
192
|
+
R.writeState(dir, cur);
|
|
193
|
+
R.append(dir, { source: src.id, ws, ev: 'start', run, trigger }, t);
|
|
194
|
+
R.append(dir, { source: src.id, ws, ev: 'end', run, result: 'failed', took_ms: 0, error: (r && r.error) || 'could not start' }, t);
|
|
195
|
+
}
|
|
196
|
+
return r || { ok: false, error: 'could not start' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ── job gates ── */
|
|
200
|
+
function jobGate(id, t = now()) {
|
|
201
|
+
const set = settings(); const state = R.readState(dir);
|
|
202
|
+
const g = R.jobGate(id, { now: t, settings: set, inputState: inputStateFn(set, state, t), waitingSince: jobWait.get(id) || null });
|
|
203
|
+
if (g.ok) { const w0 = jobWait.get(id); jobWait.delete(id); if (w0 && t - w0 >= R.WAIT_MS) expect.set(id, { trigger: 'schedule', note: 'Waited 30 min for its inputs, then ran without them' }); }
|
|
204
|
+
else if (g.why === 'waiting' && !jobWait.has(id)) jobWait.set(id, t);
|
|
205
|
+
return g;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ── presses ── */
|
|
209
|
+
function runNow(id, { ws, withInputs } = {}) {
|
|
210
|
+
const src = R.BY_ID.get(id);
|
|
211
|
+
if (!src) return { ok: false, status: 400, error: 'source must be one of ' + R.SOURCES.map((x) => x.id).join(', ') };
|
|
212
|
+
const set = settings(); const t = now();
|
|
213
|
+
const startedInputs = [];
|
|
214
|
+
if (withInputs) {
|
|
215
|
+
const rows = rowsFor(ws, set, t);
|
|
216
|
+
const me = rows.find((r) => r.id === id);
|
|
217
|
+
for (const inp of me ? me.built_on : []) {
|
|
218
|
+
if (inp.state === 'off') continue;
|
|
219
|
+
const r = runNow(inp.id, { ws });
|
|
220
|
+
if (r.ok) startedInputs.push(inp.id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (src.run === 'skill') {
|
|
224
|
+
const state = R.readState(dir);
|
|
225
|
+
if (running(state[id])) return { ok: false, status: 409, error: `${src.label} is already refreshing` };
|
|
226
|
+
const busy = src.uses.filter((u) => ['running', 'waiting', 'due'].includes(inputStateFn(set, state, t)(u)));
|
|
227
|
+
if (busy.length) {
|
|
228
|
+
state[id] = { ...(state[id] || {}), pending: { trigger: 'pressed', at: t, ws: ws || null }, waiting_since: t, waiting_for: busy };
|
|
229
|
+
R.writeState(dir, state);
|
|
230
|
+
return { ok: true, source: id, waiting: busy, inputs: startedInputs };
|
|
231
|
+
}
|
|
232
|
+
const r = start(src, ws || (set.sources[id] && set.sources[id].ws) || host.homeWs(), { trigger: 'pressed' });
|
|
233
|
+
return r.ok ? { ok: true, source: id, started: 'session', session: r.session, inputs: startedInputs } : { ok: false, status: r.status || 500, error: r.error };
|
|
234
|
+
}
|
|
235
|
+
const job = host.jobs && host.jobs[id];
|
|
236
|
+
if (!job) return { ok: false, status: 409, error: `${src.label} cannot be started from here` };
|
|
237
|
+
expect.set(id, { trigger: 'pressed' });
|
|
238
|
+
let r; try { r = job(ws); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
|
|
239
|
+
if (!r || !r.ok) { expect.delete(id); return { ok: false, status: (r && r.status) || 409, error: (r && r.error) || 'it could not start' }; }
|
|
240
|
+
return { ok: true, source: id, started: r.started || 'job', ...(r.session ? { session: r.session } : {}), inputs: startedInputs };
|
|
241
|
+
}
|
|
242
|
+
function setSource(id, patch) {
|
|
243
|
+
const src = R.BY_ID.get(id);
|
|
244
|
+
if (!src) return { ok: false, error: 'source must be one of ' + R.SOURCES.map((x) => x.id).join(', ') };
|
|
245
|
+
if (patch.on !== undefined && typeof patch.on !== 'boolean') return { ok: false, error: 'on must be true or false' };
|
|
246
|
+
let norm = null;
|
|
247
|
+
if (patch.schedule !== undefined) { norm = R.normalize(src, patch.schedule); if (!norm.ok) return norm; }
|
|
248
|
+
if (patch.on === undefined && !norm) return { ok: false, error: 'give on, schedule or both' };
|
|
249
|
+
const set = R.readSettings(dir);
|
|
250
|
+
const cur = set.sources[id];
|
|
251
|
+
if (typeof patch.on === 'boolean') cur.on = patch.on;
|
|
252
|
+
if (norm && id === 'daycheck') { try { host.setDayCheckEvery(norm.s.every); } catch (e) { return { ok: false, error: 'the day check could not be saved' }; } }
|
|
253
|
+
else if (norm) cur.s = norm.s;
|
|
254
|
+
if (id === 'repo' && typeof patch.on === 'boolean') { try { host.setRepoWatch(patch.on); } catch (e) { warn('repository watch', e); } }
|
|
255
|
+
R.writeSettings(dir, set);
|
|
256
|
+
// A schedule change starts the clock from now: a slot that passed before the change is not run late.
|
|
257
|
+
if (norm && id !== 'daycheck') { const st = R.readState(dir); const sl = R.lastSlot(norm.s, now(), win()); st[id] = { ...(st[id] || {}), last_slot: sl, retry_at: null }; R.writeState(dir, st); }
|
|
258
|
+
if (patch.on === true && src.run === 'skill') { const st = R.readState(dir); const sl = R.lastSlot(cur.s, now(), win()); st[id] = { ...(st[id] || {}), last_slot: sl }; R.writeState(dir, st); }
|
|
259
|
+
return { ok: true, source: id };
|
|
260
|
+
}
|
|
261
|
+
function setRules(patch) {
|
|
262
|
+
const n = R.normalizeRules(patch); if (!n.ok) return n;
|
|
263
|
+
const set = R.readSettings(dir); set.rules = { ...set.rules, ...n.patch }; R.writeSettings(dir, set);
|
|
264
|
+
return { ok: true, rules: set.rules };
|
|
265
|
+
}
|
|
266
|
+
/** A seed set or Prepare my day asked for a managed skill: turn its source on instead of adding a schedule (R3). */
|
|
267
|
+
function ensureOn(id, ws) {
|
|
268
|
+
const set = R.readSettings(dir); const cur = set.sources[id]; if (!cur) return false;
|
|
269
|
+
const was = cur.on; cur.on = true; if (ws && !cur.ws) cur.ws = ws;
|
|
270
|
+
set.migrated = true;
|
|
271
|
+
R.writeSettings(dir, set);
|
|
272
|
+
if (!was) { const st = R.readState(dir); st[id] = { ...(st[id] || {}), last_slot: R.lastSlot(cur.s, now(), win()) }; R.writeState(dir, st); }
|
|
273
|
+
return !was;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ── rows ── */
|
|
277
|
+
const FRESH_OF = { 'my-work': 'my-work', calendar: 'calendar', jira: 'jira', tracker: 'tracker', initiatives: 'initiatives' };
|
|
278
|
+
function rowsFor(ws, set, t) {
|
|
279
|
+
const state = R.readState(dir);
|
|
280
|
+
const view = {};
|
|
281
|
+
for (const [k, v] of Object.entries(state)) view[k] = { ...v, running: running(v) };
|
|
282
|
+
let fr = []; try { fr = host.freshRows(ws) || []; } catch { fr = []; }
|
|
283
|
+
const fresh = new Map(fr.filter((x) => FRESH_OF[x.id]).map((x) => [x.id, x]));
|
|
284
|
+
return R.rowsOf({ now: t, settings: set, state: view, log: log(), fresh, win: win(), quiet: quiet(), jobSchedule: host.jobSchedule || null, ws });
|
|
285
|
+
}
|
|
286
|
+
function rows(ws) { const set = settings(); return { rules: set.rules, quiet: quiet(), window: win(), rows: rowsFor(ws, set, now()) }; }
|
|
287
|
+
/** What lib/freshness.js shows as a skill's schedule, now that the schedule lives here. */
|
|
288
|
+
function scheduleOfSkill(skill) {
|
|
289
|
+
const id = R.SKILL_OF.get(skill); if (!id) return null;
|
|
290
|
+
const set = settings(); const x = set.sources[id]; const st = R.readState(dir)[id] || {};
|
|
291
|
+
const l = log().lastRun(id);
|
|
292
|
+
const next = x.on ? R.nextSlot(x.s, now(), win()) : null;
|
|
293
|
+
return { cadence: x.on ? R.textOf(x.s) : 'off', time: null, day: null, next_run: next ? new Date(next).toISOString() : null,
|
|
294
|
+
last_run: l ? l.start_at : null, last_status: l ? (l.result === 'failed' ? 'fail' : l.result === 'done' ? 'ok' : 'started') : st.last_result || null };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { tick, started, ended, briefDone, codeRun, jobGate, runNow, setSource, setRules, ensureOn, rows, settings, log, scheduleOfSkill, managedSkill: (skill) => R.SKILL_OF.get(skill) || null };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
module.exports = { create };
|