@try-works/dsh-memory 0.1.19 → 0.1.20
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/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +287 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +866 -866
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- package/src/workspace.js +87 -87
package/src/tasks.js
CHANGED
|
@@ -1,617 +1,617 @@
|
|
|
1
|
-
// The tasks layer: a durable, portable record of the DSH scheduled tasks this machine had.
|
|
2
|
-
//
|
|
3
|
-
// Why this exists at all. ~/.dsh/storages/schedule.json holds the only copy of every automated
|
|
4
|
-
// task: it is in no git repository and no backup. A task is owned by the session that created
|
|
5
|
-
// it, and that ownership is load-bearing twice - runtime.ts:101 resolves an agent from
|
|
6
|
-
// task.sessionId before every delivery and throws when it cannot, and index.ts:421 DELETES
|
|
7
|
-
// every active task of a session when that session stops. So tasks die with their session and
|
|
8
|
-
// nothing anywhere keeps a second copy.
|
|
9
|
-
//
|
|
10
|
-
// Two rules shape the format.
|
|
11
|
-
//
|
|
12
|
-
// PORTABLE ONLY. The stored value is the task's `record` - id, kind, title, prompt, and the
|
|
13
|
-
// selector that says when it runs. `sessionId`, `status` and `deliveryHistory` are machine-local
|
|
14
|
-
// runtime state and are never written: carrying sessionId across machines is exactly what would
|
|
15
|
-
// make a restored task fail, because the session it names does not exist there.
|
|
16
|
-
//
|
|
17
|
-
// ONE FILE PER TASK, JSON. A record must round-trip EXACTLY - a cron expression, an IANA zone,
|
|
18
|
-
// and prompts that run to thousands of characters. Markdown would need a hand-rolled format and
|
|
19
|
-
// would be one escaping bug away from a corrupted schedule. index.md is the readable overview.
|
|
20
|
-
|
|
21
|
-
import { createHash } from 'node:crypto'
|
|
22
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
23
|
-
import { homedir } from 'node:os'
|
|
24
|
-
import { join } from 'node:path'
|
|
25
|
-
import { globalDir } from './store.js'
|
|
26
|
-
import { slugify } from './workspace-key.js'
|
|
27
|
-
|
|
28
|
-
/** Directory, under a scope root, holding one file per task plus the index. */
|
|
29
|
-
export const TASKS_DIR = 'tasks'
|
|
30
|
-
export const TASK_INDEX = 'index.md'
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The lifecycle a backed-up task can rest in.
|
|
34
|
-
*
|
|
35
|
-
* The host records only two states (schedule types.ts:217, 'active' | 'inactive') and its
|
|
36
|
-
* 'inactive' means DELIVERED or recurrence-exhausted, not failed: a delivery failure throws
|
|
37
|
-
* before any commit (runtime.ts:101-102), so a task failing on every scan stays 'active' and
|
|
38
|
-
* leaves no trace. Everything below that the host cannot express is carried here instead.
|
|
39
|
-
*/
|
|
40
|
-
export const TASK_STATES = {
|
|
41
|
-
ACTIVE: 'active',
|
|
42
|
-
PENDING: 'pending',
|
|
43
|
-
PAUSED: 'paused',
|
|
44
|
-
STOPPED: 'stopped',
|
|
45
|
-
FAILED: 'failed',
|
|
46
|
-
ORPHANED: 'orphaned',
|
|
47
|
-
SKIPPED: 'skipped',
|
|
48
|
-
DONE: 'done',
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Every resting state's single exit, meaning 'something changed'.
|
|
53
|
-
*
|
|
54
|
-
* The queue in src/queue.js learned this the hard way: a state with no way out is a state a
|
|
55
|
-
* thing sits in forever, silently. A test walks this table so a new state cannot be added
|
|
56
|
-
* without one.
|
|
57
|
-
*/
|
|
58
|
-
export const TASK_STATE_EXITS = {
|
|
59
|
-
// Two entries were missing, and NOTHING noticed until writeTask began reading this table.
|
|
60
|
-
//
|
|
61
|
-
// 'orphaned': the session holding a live task can disappear while the task itself does not
|
|
62
|
-
// change, so an armed task can become one that can never fire.
|
|
63
|
-
//
|
|
64
|
-
// 'paused' in every row: holding a task back is a DECISION the user makes about a task in any
|
|
65
|
-
// condition, not a lifecycle step. Writing the table without it left the entire pause path
|
|
66
|
-
// undeclared - the feature worked, and the declaration that was supposed to describe it did not.
|
|
67
|
-
active: ['stopped', 'done', 'failed', 'orphaned', 'paused'],
|
|
68
|
-
pending: ['active', 'paused'],
|
|
69
|
-
paused: ['pending'],
|
|
70
|
-
stopped: ['pending', 'paused'],
|
|
71
|
-
failed: ['active', 'paused'],
|
|
72
|
-
orphaned: ['pending', 'paused'],
|
|
73
|
-
skipped: ['active', 'paused'],
|
|
74
|
-
done: ['active', 'paused'],
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const STATES = Object.values(TASK_STATES)
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* A stored state, or null when it is not one we know.
|
|
81
|
-
*
|
|
82
|
-
* Null rather than a throw or a silent default: an index that crashes on one bad file takes the
|
|
83
|
-
* whole layer with it, and quietly treating an unknown state as 'active' would report a task as
|
|
84
|
-
* armed when nothing has established that. The caller decides, and the index shows it verbatim.
|
|
85
|
-
*/
|
|
86
|
-
export function readState(value) {
|
|
87
|
-
return STATES.includes(value) ? value : null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Where the task files live: under the GLOBAL scope, not the store root.
|
|
92
|
-
*
|
|
93
|
-
* The layer is global-only by design - DSH tasks are not repository-scoped - so it belongs beside
|
|
94
|
-
* global/semantic.md and the rest. Resolving it against the root instead put it at <root>/tasks,
|
|
95
|
-
* a directory nothing else in the store knows about and no index or sync rule covers. Caught by
|
|
96
|
-
* looking for global/tasks in a real store and finding nothing there.
|
|
97
|
-
*/
|
|
98
|
-
export function tasksDir(root) { return join(globalDir(root), TASKS_DIR) }
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The fields that say WHEN a task runs. Used to tell one task from another.
|
|
102
|
-
*
|
|
103
|
-
* `id` and `scheduledAt` are deliberately absent. An id is minted fresh by the schedule service
|
|
104
|
-
* on every create (index.ts:256), so a restored task never keeps the id it was backed up with,
|
|
105
|
-
* and scheduledAt is recomputed from now. Keying on either would make restore re-add every task
|
|
106
|
-
* on every boot - the worst possible failure for something that runs automatically.
|
|
107
|
-
*/
|
|
108
|
-
const SELECTOR_FIELDS = ['cron', 'daily', 'weekly', 'at', 'after_seconds', 'every_seconds', 'expression', 'timeZone']
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The content identity of a task: what makes it the SAME task across a restore.
|
|
112
|
-
*
|
|
113
|
-
* Title plus kind plus whichever selector fields the record carries. Derived identically on the
|
|
114
|
-
* backup and restore sides, which is why it lives here as one function rather than being
|
|
115
|
-
* inlined twice. Two tasks sharing a title and a schedule are treated as one, which is the
|
|
116
|
-
* correct reading: no reader could tell them apart either.
|
|
117
|
-
*/
|
|
118
|
-
export function contentKey(record) {
|
|
119
|
-
if (!record || typeof record !== 'object') return ''
|
|
120
|
-
const parts = [String(record.kind ?? ''), String(record.title ?? '').trim()]
|
|
121
|
-
for (const field of SELECTOR_FIELDS) {
|
|
122
|
-
const value = record[field]
|
|
123
|
-
if (value === undefined || value === null) continue
|
|
124
|
-
parts.push(field + '=' + (typeof value === 'object' ? JSON.stringify(value) : String(value)))
|
|
125
|
-
}
|
|
126
|
-
return parts.join('|')
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* The file one task lives in: its title as a slug, plus a short digest of the content key.
|
|
131
|
-
*
|
|
132
|
-
* The digest is what makes the name safe. Two tasks can share a title - the slug alone would
|
|
133
|
-
* have them overwrite each other - and the digest is derived from the content key, so a task's
|
|
134
|
-
* file keeps its name across restarts and across a backup/restore round trip. A title with no
|
|
135
|
-
* usable characters (a fully non-Latin or punctuation-only title) falls back to the digest
|
|
136
|
-
* rather than producing '.json'.
|
|
137
|
-
*/
|
|
138
|
-
export function taskFileName(record) {
|
|
139
|
-
const key = contentKey(record)
|
|
140
|
-
const digest = createHash('sha1').update(key).digest('hex').slice(0, 8)
|
|
141
|
-
const slug = slugify(record && record.title)
|
|
142
|
-
return (slug === '' ? digest : slug + '-' + digest) + '.json'
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function taskFilePath(root, record) { return join(tasksDir(root), taskFileName(record)) }
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* What each state MEANS, in one line.
|
|
149
|
-
*
|
|
150
|
-
* Separate from deriveState's reasons on purpose. A derived reason explains a transition - why the
|
|
151
|
-
* state changed to what was just computed - and cannot be reused to caption a state that was
|
|
152
|
-
* simply read back off disk. Pairing the two is how an armed task briefly acquired the reason a
|
|
153
|
-
* stopped one carries.
|
|
154
|
-
*/
|
|
155
|
-
const STATE_MEANING = {
|
|
156
|
-
[TASK_STATES.ACTIVE]: 'armed in the live store and waiting for its next occurrence',
|
|
157
|
-
[TASK_STATES.PENDING]: 'in the backup but not in the live store; a restore re-arms it',
|
|
158
|
-
[TASK_STATES.PAUSED]: 'held back on purpose; every automatic pass leaves it alone',
|
|
159
|
-
[TASK_STATES.STOPPED]: 'was armed, and its owning session stopped, so the host deleted it',
|
|
160
|
-
[TASK_STATES.FAILED]: 'was due and has still not delivered; the host records no failure of its own',
|
|
161
|
-
[TASK_STATES.ORPHANED]: 'armed, but the session that owns it cannot be resolved, so it can never fire',
|
|
162
|
-
[TASK_STATES.SKIPPED]: 'captured, but its kind is not one we can re-arm; a restore refuses it rather than guessing',
|
|
163
|
-
[TASK_STATES.DONE]: 'delivered, or its recurrence is exhausted',
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* One index row: the task, its state, and what that state means.
|
|
168
|
-
* Exported so a test can caption a state with the real wording rather than a placeholder - a stub
|
|
169
|
-
* reason would have made the caption assertion pass while proving nothing about the page.
|
|
170
|
-
*/
|
|
171
|
-
export function describeTask(task, now) {
|
|
172
|
-
const state = task.stateKnown ? task.state : deriveState({ stored: null, live: null, now }).state
|
|
173
|
-
return {
|
|
174
|
-
...task,
|
|
175
|
-
fileName: task.file ?? '',
|
|
176
|
-
state,
|
|
177
|
-
reason: STATE_MEANING[state] ?? 'an unrecognised state, shown as found in the file',
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function ensureTasksDir(root) {
|
|
182
|
-
const dir = tasksDir(root)
|
|
183
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
184
|
-
return dir
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Write one task, replacing any file for the same content key.
|
|
189
|
-
* @returns the file path written.
|
|
190
|
-
*/
|
|
191
|
-
export function writeTask(root, record, state, nowIso) {
|
|
192
|
-
ensureTasksDir(root)
|
|
193
|
-
const file = taskFilePath(root, record)
|
|
194
|
-
// ENFORCED, not documented. This table sat in the file unread since it was written, so a state
|
|
195
|
-
// could move anywhere and nothing objected - the same shape as the vocabulary states that had a
|
|
196
|
-
// test and no producer. Refusing here means a task cannot silently leave a state by a route
|
|
197
|
-
// nobody declared, which is what makes 'every resting state has a working exit' checkable
|
|
198
|
-
// rather than aspirational.
|
|
199
|
-
const previous = readTaskFile(file)
|
|
200
|
-
if (previous && previous.stateKnown && previous.state !== state) {
|
|
201
|
-
const allowed = TASK_STATE_EXITS[previous.state] ?? []
|
|
202
|
-
if (!allowed.includes(state)) {
|
|
203
|
-
throw new Error('tasks: refusing the transition ' + previous.state + ' -> ' + state + ' for "' + String(record.title ?? '') + '"; allowed from ' + previous.state + ': ' + allowed.join(', '))
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
writeFileSync(file, JSON.stringify({ backedUpAt: nowIso, state, record }, null, 2) + '\n', 'utf8')
|
|
207
|
-
return file;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Read one task file.
|
|
212
|
-
* @returns { file, backedUpAt, state, raw, record } or null when the file is unreadable.
|
|
213
|
-
*
|
|
214
|
-
* A malformed file yields null rather than throwing: one bad file must not take the layer down,
|
|
215
|
-
* and the index reports what it could not read instead of pretending the task is absent.
|
|
216
|
-
*/
|
|
217
|
-
export function readTaskFile(file) {
|
|
218
|
-
try {
|
|
219
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
220
|
-
if (!parsed || typeof parsed !== 'object' || !parsed.record || typeof parsed.record !== 'object') return null
|
|
221
|
-
return {
|
|
222
|
-
file,
|
|
223
|
-
backedUpAt: typeof parsed.backedUpAt === 'string' ? parsed.backedUpAt : null,
|
|
224
|
-
// Kept verbatim, including when unrecognised, so the index can show what it found.
|
|
225
|
-
state: typeof parsed.state === 'string' ? parsed.state : null,
|
|
226
|
-
stateKnown: readState(parsed.state) !== null,
|
|
227
|
-
record: parsed.record,
|
|
228
|
-
}
|
|
229
|
-
} catch {
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Every task file in the layer, ordered by title so the index is stable between runs.
|
|
236
|
-
* @returns { tasks, unreadable } - unreadable names the files that could not be parsed.
|
|
237
|
-
*/
|
|
238
|
-
export function listTasks(root) {
|
|
239
|
-
const dir = tasksDir(root)
|
|
240
|
-
if (!existsSync(dir)) return { tasks: [], unreadable: [] }
|
|
241
|
-
const tasks = [];
|
|
242
|
-
const unreadable = [];
|
|
243
|
-
for (const name of readdirSync(dir)) {
|
|
244
|
-
if (!name.endsWith('.json')) continue;
|
|
245
|
-
const task = readTaskFile(join(dir, name));
|
|
246
|
-
if (task === null) unreadable.push(name);
|
|
247
|
-
else tasks.push(task);
|
|
248
|
-
}
|
|
249
|
-
tasks.sort((a, b) => String(a.record.title ?? '').localeCompare(String(b.record.title ?? '')));
|
|
250
|
-
unreadable.sort();
|
|
251
|
-
return { tasks, unreadable };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
/** How long past its due time an armed task may sit before we call it failed. */
|
|
255
|
-
export const OVERDUE_MS = 6 * 60 * 60 * 1000
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Derive a task's state from what can actually be observed.
|
|
259
|
-
*
|
|
260
|
-
* This is the honest version of the vocabulary, and three entries are narrower than they first
|
|
261
|
-
* look, because the host does not publish what they name:
|
|
262
|
-
*
|
|
263
|
-
* - FAILED is not a status the host records. A delivery failure throws before any commit
|
|
264
|
-
* (runtime.ts:101-102), so a task failing on every scan stays 'active' with no trace. What IS
|
|
265
|
-
* observable is a task still armed long after the moment it was due, which is what this
|
|
266
|
-
* reports - 'due at X, still not delivered' rather than a claim about why.
|
|
267
|
-
* - ORPHANED is a live row whose owning session cannot be resolved, so it can never fire while
|
|
268
|
-
* still occupying the store. Detecting that needs the session lookup, which the caller does;
|
|
269
|
-
* it is passed in rather than guessed here.
|
|
270
|
-
* - SKIPPED is a live task with no backup file: seen, deliberately not captured.
|
|
271
|
-
*
|
|
272
|
-
* PAUSED outranks everything. An automatic restore must never silently re-arm something that was
|
|
273
|
-
* held back on purpose - that surprise is the thing this design exists to prevent.
|
|
274
|
-
*
|
|
275
|
-
* @returns { state, reason } - the reason is recorded so a reader is told why, not just what.
|
|
276
|
-
*/
|
|
277
|
-
export function deriveState({ stored, live, now = Date.now(), ownerResolvable = true }) {
|
|
278
|
-
if (stored === TASK_STATES.PAUSED) {
|
|
279
|
-
return { state: TASK_STATES.PAUSED, reason: 'held back on purpose; automatic restore leaves it alone' };
|
|
280
|
-
}
|
|
281
|
-
if (live) {
|
|
282
|
-
if (!ownerResolvable) {
|
|
283
|
-
return { state: TASK_STATES.ORPHANED, reason: 'armed, but the session that owns it cannot be resolved, so it can never fire' };
|
|
284
|
-
}
|
|
285
|
-
if (live.status === 'inactive') {
|
|
286
|
-
return { state: TASK_STATES.DONE, reason: 'delivered, or its recurrence is exhausted' };
|
|
287
|
-
}
|
|
288
|
-
const due = Date.parse(live.record && live.record.scheduledAt ? live.record.scheduledAt : '');
|
|
289
|
-
if (Number.isFinite(due) && due < now - OVERDUE_MS) {
|
|
290
|
-
return { state: TASK_STATES.FAILED, reason: 'was due at ' + new Date(due).toISOString() + ' and has still not delivered' };
|
|
291
|
-
}
|
|
292
|
-
return { state: TASK_STATES.ACTIVE, reason: 'armed in the live store and waiting for its next occurrence' };
|
|
293
|
-
}
|
|
294
|
-
if (stored === TASK_STATES.ACTIVE) {
|
|
295
|
-
return { state: TASK_STATES.STOPPED, reason: 'was armed; the host deletes a task when its session stops' };
|
|
296
|
-
}
|
|
297
|
-
return { state: TASK_STATES.PENDING, reason: 'in the backup, not in the live store; a restore re-arms it' };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Render the layer index.
|
|
302
|
-
*
|
|
303
|
-
* Readable and diffable, which is the whole reason the tasks are files rather than one blob: a
|
|
304
|
-
* git diff of this page is how you see that a task appeared, changed state, or went missing.
|
|
305
|
-
* Unreadable files are listed rather than omitted - an index that hides what it could not parse
|
|
306
|
-
* reports a smaller world than exists.
|
|
307
|
-
*/
|
|
308
|
-
export function renderTaskIndex(rows, { unreadable = [], nowIso, error = null } = {}) {
|
|
309
|
-
const out = ['# Scheduled tasks', ''];
|
|
310
|
-
out.push('Portable backup of the DSH scheduled tasks on this machine. One file per task, plus');
|
|
311
|
-
out.push('this index. Machine-local state (the owning session, delivery history) is never stored.');
|
|
312
|
-
out.push('');
|
|
313
|
-
out.push('Generated ' + nowIso + '. ' + rows.length + ' task(s).');
|
|
314
|
-
// A page that stayed silent while the backup could not read the store would read as a healthy
|
|
315
|
-
// machine with nothing to do. The failure belongs on the page, not only in a return value.
|
|
316
|
-
if (error) {
|
|
317
|
-
out.push('');
|
|
318
|
-
out.push('> **The last backup could not read the schedule store.** ' + String(error));
|
|
319
|
-
out.push('>');
|
|
320
|
-
out.push('> The tasks below are what memory already holds; they are not a fresh reading.');
|
|
321
|
-
}
|
|
322
|
-
out.push('');
|
|
323
|
-
if (rows.length === 0) {
|
|
324
|
-
out.push('No tasks are backed up yet. The backup runs on the memory sync cadence, or on demand');
|
|
325
|
-
out.push('with memory_tasks.');
|
|
326
|
-
out.push('');
|
|
327
|
-
} else {
|
|
328
|
-
out.push('| task | kind | schedule | state | backed up |');
|
|
329
|
-
out.push('| --- | --- | --- | --- | --- |');
|
|
330
|
-
for (const row of rows) {
|
|
331
|
-
out.push('| ' + cell(row.record.title) + ' | ' + cell(row.record.kind) + ' | ' + cell(scheduleOf(row.record)) + ' | ' + cell(stateCell(row)) + ' | ' + cell(row.backedUpAt) + ' |');
|
|
332
|
-
}
|
|
333
|
-
out.push('');
|
|
334
|
-
for (const row of rows) {
|
|
335
|
-
out.push('## ' + String(row.record.title ?? '(untitled)'));
|
|
336
|
-
out.push('');
|
|
337
|
-
out.push('- file: `' + row.fileName + '`');
|
|
338
|
-
out.push('- id: `' + String(row.record.id ?? '') + '` (diagnostic only; a restore mints a new one)');
|
|
339
|
-
out.push('- state: **' + row.state + '** - ' + row.reason);
|
|
340
|
-
out.push('');
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
if (unreadable.length > 0) {
|
|
344
|
-
out.push('## Could not be read');
|
|
345
|
-
out.push('');
|
|
346
|
-
for (const name of unreadable) out.push('- `' + name + '` - not valid task JSON, so it is neither counted nor drawn');
|
|
347
|
-
out.push('');
|
|
348
|
-
}
|
|
349
|
-
return out.join('\n');
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** A markdown table cell: a pipe or a newline in a title would otherwise break the row. */
|
|
353
|
-
function cell(value) {
|
|
354
|
-
return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** The schedule in words, so the index is readable without decoding the record. */
|
|
358
|
-
function scheduleOf(record) {
|
|
359
|
-
const kind = String(record.kind ?? '');
|
|
360
|
-
const zone = record.timeZone ? ' ' + record.timeZone : '';
|
|
361
|
-
if (kind === 'cron') return 'cron ' + String(record.expression ?? '') + zone;
|
|
362
|
-
if (kind === 'weekly') return 'weekly ' + JSON.stringify(record.weekly ?? {}) + zone;
|
|
363
|
-
if (kind === 'daily') return 'daily ' + JSON.stringify(record.daily ?? {}) + zone;
|
|
364
|
-
// The same camelCase/snake_case confusion as `after` above, in the same function. Writing a test
|
|
365
|
-
// to pin ONE of them surfaced the other.
|
|
366
|
-
if (kind === 'every') return 'every ' + String(record.everySeconds ?? '') + 's';
|
|
367
|
-
if (kind === 'at') return 'at ' + String(record.scheduledAt ?? '');
|
|
368
|
-
// `afterSeconds`, not `after_seconds` - the record uses camelCase, the CREATE REQUEST uses
|
|
369
|
-
// snake_case. The live index printed "after s" with the interval missing, which no test saw
|
|
370
|
-
// because no test rendered an `after` record.
|
|
371
|
-
if (kind === 'after') return 'after ' + String(record.afterSeconds ?? '') + 's';
|
|
372
|
-
return kind;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** The state cell: an unrecognised stored state is shown as found, never silently defaulted. */
|
|
376
|
-
function stateCell(row) {
|
|
377
|
-
return row.stateKnown === false ? row.state + ' (unknown state in file)' : row.state;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** The kinds the schedule plugin can store (types.ts ScheduleRecord union). */
|
|
381
|
-
export const KNOWN_KINDS = ['after', 'at', 'every', 'daily', 'weekly', 'cron']
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Where the schedule plugin keeps its tasks: a storage unit under the DSH home.
|
|
385
|
-
*
|
|
386
|
-
* DSH_HOME is the harness's own override (boot/app-boot reads it), defaulting to ~/.dsh - the
|
|
387
|
-
* same resolution the harness uses, so the plugin finds the store without being told.
|
|
388
|
-
*/
|
|
389
|
-
export function defaultSchedulePath(home = homedir()) {
|
|
390
|
-
const configured = process.env.DSH_HOME
|
|
391
|
-
const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
|
|
392
|
-
return join(dshHome, 'storages', 'schedule.json')
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Read the live schedule store.
|
|
397
|
-
* @returns { tasks, error, missing } where tasks are the rows as stored.
|
|
398
|
-
*
|
|
399
|
-
* A missing file is not an error: a machine that has never scheduled anything has no store, and
|
|
400
|
-
* the backup pass must read that as 'nothing to do' rather than as a failure. A file that exists
|
|
401
|
-
* but cannot be read IS reported, because writing an index over an unreadable store would quietly
|
|
402
|
-
* claim the machine has no tasks.
|
|
403
|
-
*/
|
|
404
|
-
export function readScheduleStore(path) {
|
|
405
|
-
if (!existsSync(path)) return { tasks: [], error: null, missing: true }
|
|
406
|
-
let parsed
|
|
407
|
-
try {
|
|
408
|
-
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
409
|
-
} catch (error) {
|
|
410
|
-
return { tasks: [], error: 'unreadable: ' + (error && error.message ? error.message : String(error)), missing: false }
|
|
411
|
-
}
|
|
412
|
-
const table = parsed && parsed.tables && parsed.tables.tasks
|
|
413
|
-
if (!table || typeof table !== 'object') return { tasks: [], error: 'no tables.tasks in the schedule store', missing: false }
|
|
414
|
-
return { tasks: Object.keys(table).map((id) => ({ id, ...table[id] })), error: null, missing: false }
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Back up every live task, and refresh the state of every task already backed up.
|
|
419
|
-
*
|
|
420
|
-
* ADDITIVE, ALWAYS. Nothing here deletes: a task gone from the live store keeps its file and has
|
|
421
|
-
* its state rewritten to say so. A stale backup must never be able to wipe a schedule, and a task
|
|
422
|
-
* deleted on purpose must stay visible as gone rather than vanish from the record of what this
|
|
423
|
-
* machine used to run.
|
|
424
|
-
*
|
|
425
|
-
* A task whose kind we do not recognise is stored and flagged SKIPPED: dropping it would mean losing
|
|
426
|
-
* a task from a backup, and flagging it means a restore refuses it rather than guessing.
|
|
427
|
-
*/
|
|
428
|
-
export function backupTasks(root, { schedulePath, now = Date.now(), nowIso, sessions } = {}) {
|
|
429
|
-
const at = nowIso ?? new Date(now).toISOString()
|
|
430
|
-
const live = readScheduleStore(schedulePath ?? defaultSchedulePath())
|
|
431
|
-
// Read once per pass, not per task: it is a directory walk, and every row asks the same question.
|
|
432
|
-
// Injectable so a test can state the world instead of inheriting this machine's: `null` means
|
|
433
|
-
// the question cannot be answered and no row is called orphaned.
|
|
434
|
-
const sessionSet = sessions === undefined ? sessionIdsOnDisk() : sessions
|
|
435
|
-
const before = listTasks(root)
|
|
436
|
-
if (live.error !== null) {
|
|
437
|
-
// The index is still rewritten, so a page from an earlier pass cannot sit on disk describing a
|
|
438
|
-
// store this pass could not read.
|
|
439
|
-
writeTaskIndex(root, before.tasks.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at, error: live.error })
|
|
440
|
-
return { written: 0, refreshed: 0, skipped: [], stopped: 0, unreadable: before.unreadable, error: live.error }
|
|
441
|
-
}
|
|
442
|
-
const byKey = new Map()
|
|
443
|
-
for (const task of before.tasks) byKey.set(contentKey(task.record), task)
|
|
444
|
-
let written = 0
|
|
445
|
-
let refreshed = 0
|
|
446
|
-
const skipped = []
|
|
447
|
-
const seen = new Set()
|
|
448
|
-
for (const task of live.tasks) {
|
|
449
|
-
const record = task.record
|
|
450
|
-
if (!record || typeof record !== 'object') { skipped.push({ id: task.id, reason: 'no record' }); continue }
|
|
451
|
-
if (!KNOWN_KINDS.includes(String(record.kind))) {
|
|
452
|
-
// CAPTURED, not dropped. An earlier version skipped these entirely, which meant a task whose
|
|
453
|
-
// kind we did not recognise silently vanished from a BACKUP - the one job this layer has. It
|
|
454
|
-
// is written and flagged 'skipped' instead, and planRestore refuses that state, so it can
|
|
455
|
-
// never be blindly re-armed. Preserved, visible, and unrestorable: all three on purpose.
|
|
456
|
-
skipped.push({ id: task.id, reason: 'unrecognised kind: ' + String(record.kind) })
|
|
457
|
-
writeTask(root, record, TASK_STATES.SKIPPED, byKey.get(contentKey(record))?.backedUpAt ?? at)
|
|
458
|
-
seen.add(contentKey(record))
|
|
459
|
-
continue
|
|
460
|
-
}
|
|
461
|
-
const key = contentKey(record)
|
|
462
|
-
seen.add(key)
|
|
463
|
-
const previous = byKey.get(key)
|
|
464
|
-
// A held-back task stays held back across every backup: that state is the user's decision, and
|
|
465
|
-
// a routine pass must not overwrite it with a derived one.
|
|
466
|
-
const held = previous && previous.stateKnown && previous.state === TASK_STATES.PAUSED
|
|
467
|
-
// Without this the ORPHANED state exists in the vocabulary and is never produced by any real
|
|
468
|
-
// pass - reachable from a unit test and from nothing else. `sessions === null` means the
|
|
469
|
-
// sessions directory is absent and we cannot tell, so the row is left as active.
|
|
470
|
-
const ownerResolvable = sessionSet === null || task.sessionId === undefined ? true : sessionSet.has(task.sessionId)
|
|
471
|
-
const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now, ownerResolvable })
|
|
472
|
-
writeTask(root, record, derived.state, previous ? previous.backedUpAt : at)
|
|
473
|
-
if (previous) refreshed += 1
|
|
474
|
-
else written += 1
|
|
475
|
-
}
|
|
476
|
-
let stopped = 0
|
|
477
|
-
const rows = []
|
|
478
|
-
for (const task of listTasks(root).tasks) {
|
|
479
|
-
if (seen.has(contentKey(task.record))) { rows.push(task); continue }
|
|
480
|
-
const derived = deriveState({ stored: task.stateKnown ? task.state : null, live: null, now })
|
|
481
|
-
if (derived.state !== task.state) {
|
|
482
|
-
writeTask(root, task.record, derived.state, task.backedUpAt ?? at)
|
|
483
|
-
stopped += 1
|
|
484
|
-
}
|
|
485
|
-
rows.push({ ...task, state: derived.state })
|
|
486
|
-
}
|
|
487
|
-
writeTaskIndex(root, rows.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at })
|
|
488
|
-
return { written, refreshed, skipped, stopped, unreadable: before.unreadable, error: null, rows, nowIso: at }
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Write the layer index.
|
|
493
|
-
*
|
|
494
|
-
* Regenerated on every pass rather than appended: an index that only grows would report tasks the
|
|
495
|
-
* machine no longer runs as though they were still there. The per-task files are the truth; this
|
|
496
|
-
* page is a view of them, which is why losing it costs nothing.
|
|
497
|
-
* @returns the index path.
|
|
498
|
-
*/
|
|
499
|
-
export function writeTaskIndex(root, rows, { unreadable = [], nowIso, error = null } = {}) {
|
|
500
|
-
ensureTasksDir(root)
|
|
501
|
-
const file = join(tasksDir(root), TASK_INDEX)
|
|
502
|
-
writeFileSync(file, renderTaskIndex(rows, { unreadable, nowIso, error }), 'utf8')
|
|
503
|
-
return file
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Translate a stored record back into the request the schedule service accepts.
|
|
508
|
-
*
|
|
509
|
-
* The two shapes do NOT share field names, and the differences are the kind that fail silently:
|
|
510
|
-
* a record stores `timeZone` and `everySeconds` (types.ts ScheduleRecord union), while
|
|
511
|
-
* ScheduleCreateRequest takes `time_zone` and `every_seconds`. Passing the record straight
|
|
512
|
-
* through would drop the zone - a task would be re-armed in the wrong time zone, or refused, and
|
|
513
|
-
* nothing in the record would look wrong afterwards.
|
|
514
|
-
*
|
|
515
|
-
* One-shot kinds are REFUSED rather than approximated. An `at` record keeps only its resolved
|
|
516
|
-
* instant, which for any task that already ran is in the past, and an `after` record keeps a
|
|
517
|
-
* relative delay whose clock would restart from now. Re-arming either would fabricate a schedule
|
|
518
|
-
* the user never set, so the caller is told instead.
|
|
519
|
-
*
|
|
520
|
-
* @returns { request } or { error } naming exactly what could not be translated.
|
|
521
|
-
*/
|
|
522
|
-
export function toCreateRequest(record) {
|
|
523
|
-
if (!record || typeof record !== 'object') return { error: 'no record' }
|
|
524
|
-
const title = String(record.title ?? '').trim()
|
|
525
|
-
const prompt = String(record.prompt ?? '')
|
|
526
|
-
if (title === '') return { error: 'no title' }
|
|
527
|
-
if (prompt === '') return { error: 'no prompt' }
|
|
528
|
-
const kind = String(record.kind ?? '')
|
|
529
|
-
const zone = record.timeZone
|
|
530
|
-
if (kind === 'cron') {
|
|
531
|
-
if (typeof record.expression !== 'string' || record.expression === '') return { error: 'cron with no expression' }
|
|
532
|
-
if (typeof zone !== 'string' || zone === '') return { error: 'cron with no time zone' }
|
|
533
|
-
return { request: { title, prompt, cron: { expression: record.expression, time_zone: zone } } }
|
|
534
|
-
}
|
|
535
|
-
if (kind === 'daily') {
|
|
536
|
-
if (typeof record.time !== 'string' || record.time === '') return { error: 'daily with no time' }
|
|
537
|
-
if (typeof zone !== 'string' || zone === '') return { error: 'daily with no time zone' }
|
|
538
|
-
return { request: { title, prompt, daily: { time: record.time, time_zone: zone } } }
|
|
539
|
-
}
|
|
540
|
-
if (kind === 'weekly') {
|
|
541
|
-
if (typeof record.time !== 'string' || record.time === '') return { error: 'weekly with no time' }
|
|
542
|
-
if (typeof zone !== 'string' || zone === '') return { error: 'weekly with no time zone' }
|
|
543
|
-
if (!Array.isArray(record.weekdays) || record.weekdays.length === 0) return { error: 'weekly with no weekdays' }
|
|
544
|
-
return { request: { title, prompt, weekly: { time: record.time, time_zone: zone, weekdays: [...record.weekdays] } } }
|
|
545
|
-
}
|
|
546
|
-
if (kind === 'every') {
|
|
547
|
-
if (!Number.isFinite(record.everySeconds) || record.everySeconds <= 0) return { error: 'every with no interval' }
|
|
548
|
-
return { request: { title, prompt, every_seconds: record.everySeconds } }
|
|
549
|
-
}
|
|
550
|
-
if (kind === 'at' || kind === 'after') {
|
|
551
|
-
return { error: 'a one-shot ' + kind + ' task is not restorable: it either already fired or its target is in the past, and re-arming it would invent a schedule that was never set' }
|
|
552
|
-
}
|
|
553
|
-
return { error: 'unrecognised kind: ' + kind }
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* Decide what a restore would do, without doing any of it.
|
|
558
|
-
*
|
|
559
|
-
* Pure, so the decision can be tested and shown before anything is written. Additive and
|
|
560
|
-
* de-duplicating by CONTENT: the service mints a fresh id on every create (schedule index.ts:256),
|
|
561
|
-
* so a restored task never keeps the id it was backed up with, and matching on ids would find
|
|
562
|
-
* nothing on the second boot and re-add everything.
|
|
563
|
-
*
|
|
564
|
-
* @returns { add, present, paused, refused } - one entry per backed-up task, each saying why.
|
|
565
|
-
*/
|
|
566
|
-
export function planRestore(root, { liveTasks = [], now = Date.now() } = {}) {
|
|
567
|
-
const live = new Set(liveTasks.map((task) => contentKey(task.record)))
|
|
568
|
-
const { tasks, unreadable } = listTasks(root)
|
|
569
|
-
const add = []
|
|
570
|
-
const present = []
|
|
571
|
-
const paused = []
|
|
572
|
-
const refused = []
|
|
573
|
-
for (const task of tasks) {
|
|
574
|
-
const key = contentKey(task.record)
|
|
575
|
-
if (live.has(key)) { present.push({ task, reason: 'already in the live store, under whatever id it now carries' }); continue }
|
|
576
|
-
if (task.stateKnown && task.state === TASK_STATES.PAUSED) { paused.push({ task, reason: 'held back on purpose; automatic restore leaves it alone' }); continue }
|
|
577
|
-
if (task.stateKnown && task.state === TASK_STATES.SKIPPED) { refused.push({ task, reason: 'recorded as skipped' }); continue }
|
|
578
|
-
const translated = toCreateRequest(task.record)
|
|
579
|
-
if (translated.error) refused.push({ task, reason: translated.error })
|
|
580
|
-
else add.push({ task, request: translated.request })
|
|
581
|
-
}
|
|
582
|
-
return { add, present, paused, refused, unreadable }
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** The directory DSH keeps session records in, beside the schedule store's home. */
|
|
586
|
-
export function sessionsRoot(home = homedir()) {
|
|
587
|
-
const configured = process.env.DSH_HOME
|
|
588
|
-
const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
|
|
589
|
-
return join(dshHome, 'sessions')
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* Every session id that still has a record on disk.
|
|
594
|
-
*
|
|
595
|
-
* Used to tell ORPHANED from ACTIVE. A live task is armed against the session that created it, and
|
|
596
|
-
* the runtime resolves an agent from that id before every delivery (schedule runtime.ts:101) - so
|
|
597
|
-
* a task whose session is gone is a row that will never fire while still occupying the store. The
|
|
598
|
-
* backup cannot see the runtime, but it can see whether the session still exists.
|
|
599
|
-
*
|
|
600
|
-
* @returns a Set, or null when the sessions directory is absent
|
|
601
|
-
*
|
|
602
|
-
* Null rather than an empty set on purpose: no sessions directory means we cannot tell, and an
|
|
603
|
-
* empty set would mark every task on the machine orphaned. Refusing to guess is the rule here.
|
|
604
|
-
*/
|
|
605
|
-
export function sessionIdsOnDisk(home = homedir()) {
|
|
606
|
-
const root = sessionsRoot(home)
|
|
607
|
-
if (!existsSync(root)) return null
|
|
608
|
-
const ids = new Set()
|
|
609
|
-
let projects
|
|
610
|
-
try { projects = readdirSync(root) } catch { return null }
|
|
611
|
-
for (const project of projects) {
|
|
612
|
-
let sessions
|
|
613
|
-
try { sessions = readdirSync(join(root, project)) } catch { continue }
|
|
614
|
-
for (const id of sessions) ids.add(id)
|
|
615
|
-
}
|
|
616
|
-
return ids
|
|
617
|
-
}
|
|
1
|
+
// The tasks layer: a durable, portable record of the DSH scheduled tasks this machine had.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists at all. ~/.dsh/storages/schedule.json holds the only copy of every automated
|
|
4
|
+
// task: it is in no git repository and no backup. A task is owned by the session that created
|
|
5
|
+
// it, and that ownership is load-bearing twice - runtime.ts:101 resolves an agent from
|
|
6
|
+
// task.sessionId before every delivery and throws when it cannot, and index.ts:421 DELETES
|
|
7
|
+
// every active task of a session when that session stops. So tasks die with their session and
|
|
8
|
+
// nothing anywhere keeps a second copy.
|
|
9
|
+
//
|
|
10
|
+
// Two rules shape the format.
|
|
11
|
+
//
|
|
12
|
+
// PORTABLE ONLY. The stored value is the task's `record` - id, kind, title, prompt, and the
|
|
13
|
+
// selector that says when it runs. `sessionId`, `status` and `deliveryHistory` are machine-local
|
|
14
|
+
// runtime state and are never written: carrying sessionId across machines is exactly what would
|
|
15
|
+
// make a restored task fail, because the session it names does not exist there.
|
|
16
|
+
//
|
|
17
|
+
// ONE FILE PER TASK, JSON. A record must round-trip EXACTLY - a cron expression, an IANA zone,
|
|
18
|
+
// and prompts that run to thousands of characters. Markdown would need a hand-rolled format and
|
|
19
|
+
// would be one escaping bug away from a corrupted schedule. index.md is the readable overview.
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { globalDir } from './store.js'
|
|
26
|
+
import { slugify } from './workspace-key.js'
|
|
27
|
+
|
|
28
|
+
/** Directory, under a scope root, holding one file per task plus the index. */
|
|
29
|
+
export const TASKS_DIR = 'tasks'
|
|
30
|
+
export const TASK_INDEX = 'index.md'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The lifecycle a backed-up task can rest in.
|
|
34
|
+
*
|
|
35
|
+
* The host records only two states (schedule types.ts:217, 'active' | 'inactive') and its
|
|
36
|
+
* 'inactive' means DELIVERED or recurrence-exhausted, not failed: a delivery failure throws
|
|
37
|
+
* before any commit (runtime.ts:101-102), so a task failing on every scan stays 'active' and
|
|
38
|
+
* leaves no trace. Everything below that the host cannot express is carried here instead.
|
|
39
|
+
*/
|
|
40
|
+
export const TASK_STATES = {
|
|
41
|
+
ACTIVE: 'active',
|
|
42
|
+
PENDING: 'pending',
|
|
43
|
+
PAUSED: 'paused',
|
|
44
|
+
STOPPED: 'stopped',
|
|
45
|
+
FAILED: 'failed',
|
|
46
|
+
ORPHANED: 'orphaned',
|
|
47
|
+
SKIPPED: 'skipped',
|
|
48
|
+
DONE: 'done',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every resting state's single exit, meaning 'something changed'.
|
|
53
|
+
*
|
|
54
|
+
* The queue in src/queue.js learned this the hard way: a state with no way out is a state a
|
|
55
|
+
* thing sits in forever, silently. A test walks this table so a new state cannot be added
|
|
56
|
+
* without one.
|
|
57
|
+
*/
|
|
58
|
+
export const TASK_STATE_EXITS = {
|
|
59
|
+
// Two entries were missing, and NOTHING noticed until writeTask began reading this table.
|
|
60
|
+
//
|
|
61
|
+
// 'orphaned': the session holding a live task can disappear while the task itself does not
|
|
62
|
+
// change, so an armed task can become one that can never fire.
|
|
63
|
+
//
|
|
64
|
+
// 'paused' in every row: holding a task back is a DECISION the user makes about a task in any
|
|
65
|
+
// condition, not a lifecycle step. Writing the table without it left the entire pause path
|
|
66
|
+
// undeclared - the feature worked, and the declaration that was supposed to describe it did not.
|
|
67
|
+
active: ['stopped', 'done', 'failed', 'orphaned', 'paused'],
|
|
68
|
+
pending: ['active', 'paused'],
|
|
69
|
+
paused: ['pending'],
|
|
70
|
+
stopped: ['pending', 'paused'],
|
|
71
|
+
failed: ['active', 'paused'],
|
|
72
|
+
orphaned: ['pending', 'paused'],
|
|
73
|
+
skipped: ['active', 'paused'],
|
|
74
|
+
done: ['active', 'paused'],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const STATES = Object.values(TASK_STATES)
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A stored state, or null when it is not one we know.
|
|
81
|
+
*
|
|
82
|
+
* Null rather than a throw or a silent default: an index that crashes on one bad file takes the
|
|
83
|
+
* whole layer with it, and quietly treating an unknown state as 'active' would report a task as
|
|
84
|
+
* armed when nothing has established that. The caller decides, and the index shows it verbatim.
|
|
85
|
+
*/
|
|
86
|
+
export function readState(value) {
|
|
87
|
+
return STATES.includes(value) ? value : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where the task files live: under the GLOBAL scope, not the store root.
|
|
92
|
+
*
|
|
93
|
+
* The layer is global-only by design - DSH tasks are not repository-scoped - so it belongs beside
|
|
94
|
+
* global/semantic.md and the rest. Resolving it against the root instead put it at <root>/tasks,
|
|
95
|
+
* a directory nothing else in the store knows about and no index or sync rule covers. Caught by
|
|
96
|
+
* looking for global/tasks in a real store and finding nothing there.
|
|
97
|
+
*/
|
|
98
|
+
export function tasksDir(root) { return join(globalDir(root), TASKS_DIR) }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The fields that say WHEN a task runs. Used to tell one task from another.
|
|
102
|
+
*
|
|
103
|
+
* `id` and `scheduledAt` are deliberately absent. An id is minted fresh by the schedule service
|
|
104
|
+
* on every create (index.ts:256), so a restored task never keeps the id it was backed up with,
|
|
105
|
+
* and scheduledAt is recomputed from now. Keying on either would make restore re-add every task
|
|
106
|
+
* on every boot - the worst possible failure for something that runs automatically.
|
|
107
|
+
*/
|
|
108
|
+
const SELECTOR_FIELDS = ['cron', 'daily', 'weekly', 'at', 'after_seconds', 'every_seconds', 'expression', 'timeZone']
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The content identity of a task: what makes it the SAME task across a restore.
|
|
112
|
+
*
|
|
113
|
+
* Title plus kind plus whichever selector fields the record carries. Derived identically on the
|
|
114
|
+
* backup and restore sides, which is why it lives here as one function rather than being
|
|
115
|
+
* inlined twice. Two tasks sharing a title and a schedule are treated as one, which is the
|
|
116
|
+
* correct reading: no reader could tell them apart either.
|
|
117
|
+
*/
|
|
118
|
+
export function contentKey(record) {
|
|
119
|
+
if (!record || typeof record !== 'object') return ''
|
|
120
|
+
const parts = [String(record.kind ?? ''), String(record.title ?? '').trim()]
|
|
121
|
+
for (const field of SELECTOR_FIELDS) {
|
|
122
|
+
const value = record[field]
|
|
123
|
+
if (value === undefined || value === null) continue
|
|
124
|
+
parts.push(field + '=' + (typeof value === 'object' ? JSON.stringify(value) : String(value)))
|
|
125
|
+
}
|
|
126
|
+
return parts.join('|')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The file one task lives in: its title as a slug, plus a short digest of the content key.
|
|
131
|
+
*
|
|
132
|
+
* The digest is what makes the name safe. Two tasks can share a title - the slug alone would
|
|
133
|
+
* have them overwrite each other - and the digest is derived from the content key, so a task's
|
|
134
|
+
* file keeps its name across restarts and across a backup/restore round trip. A title with no
|
|
135
|
+
* usable characters (a fully non-Latin or punctuation-only title) falls back to the digest
|
|
136
|
+
* rather than producing '.json'.
|
|
137
|
+
*/
|
|
138
|
+
export function taskFileName(record) {
|
|
139
|
+
const key = contentKey(record)
|
|
140
|
+
const digest = createHash('sha1').update(key).digest('hex').slice(0, 8)
|
|
141
|
+
const slug = slugify(record && record.title)
|
|
142
|
+
return (slug === '' ? digest : slug + '-' + digest) + '.json'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function taskFilePath(root, record) { return join(tasksDir(root), taskFileName(record)) }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* What each state MEANS, in one line.
|
|
149
|
+
*
|
|
150
|
+
* Separate from deriveState's reasons on purpose. A derived reason explains a transition - why the
|
|
151
|
+
* state changed to what was just computed - and cannot be reused to caption a state that was
|
|
152
|
+
* simply read back off disk. Pairing the two is how an armed task briefly acquired the reason a
|
|
153
|
+
* stopped one carries.
|
|
154
|
+
*/
|
|
155
|
+
const STATE_MEANING = {
|
|
156
|
+
[TASK_STATES.ACTIVE]: 'armed in the live store and waiting for its next occurrence',
|
|
157
|
+
[TASK_STATES.PENDING]: 'in the backup but not in the live store; a restore re-arms it',
|
|
158
|
+
[TASK_STATES.PAUSED]: 'held back on purpose; every automatic pass leaves it alone',
|
|
159
|
+
[TASK_STATES.STOPPED]: 'was armed, and its owning session stopped, so the host deleted it',
|
|
160
|
+
[TASK_STATES.FAILED]: 'was due and has still not delivered; the host records no failure of its own',
|
|
161
|
+
[TASK_STATES.ORPHANED]: 'armed, but the session that owns it cannot be resolved, so it can never fire',
|
|
162
|
+
[TASK_STATES.SKIPPED]: 'captured, but its kind is not one we can re-arm; a restore refuses it rather than guessing',
|
|
163
|
+
[TASK_STATES.DONE]: 'delivered, or its recurrence is exhausted',
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One index row: the task, its state, and what that state means.
|
|
168
|
+
* Exported so a test can caption a state with the real wording rather than a placeholder - a stub
|
|
169
|
+
* reason would have made the caption assertion pass while proving nothing about the page.
|
|
170
|
+
*/
|
|
171
|
+
export function describeTask(task, now) {
|
|
172
|
+
const state = task.stateKnown ? task.state : deriveState({ stored: null, live: null, now }).state
|
|
173
|
+
return {
|
|
174
|
+
...task,
|
|
175
|
+
fileName: task.file ?? '',
|
|
176
|
+
state,
|
|
177
|
+
reason: STATE_MEANING[state] ?? 'an unrecognised state, shown as found in the file',
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function ensureTasksDir(root) {
|
|
182
|
+
const dir = tasksDir(root)
|
|
183
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
184
|
+
return dir
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Write one task, replacing any file for the same content key.
|
|
189
|
+
* @returns the file path written.
|
|
190
|
+
*/
|
|
191
|
+
export function writeTask(root, record, state, nowIso) {
|
|
192
|
+
ensureTasksDir(root)
|
|
193
|
+
const file = taskFilePath(root, record)
|
|
194
|
+
// ENFORCED, not documented. This table sat in the file unread since it was written, so a state
|
|
195
|
+
// could move anywhere and nothing objected - the same shape as the vocabulary states that had a
|
|
196
|
+
// test and no producer. Refusing here means a task cannot silently leave a state by a route
|
|
197
|
+
// nobody declared, which is what makes 'every resting state has a working exit' checkable
|
|
198
|
+
// rather than aspirational.
|
|
199
|
+
const previous = readTaskFile(file)
|
|
200
|
+
if (previous && previous.stateKnown && previous.state !== state) {
|
|
201
|
+
const allowed = TASK_STATE_EXITS[previous.state] ?? []
|
|
202
|
+
if (!allowed.includes(state)) {
|
|
203
|
+
throw new Error('tasks: refusing the transition ' + previous.state + ' -> ' + state + ' for "' + String(record.title ?? '') + '"; allowed from ' + previous.state + ': ' + allowed.join(', '))
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
writeFileSync(file, JSON.stringify({ backedUpAt: nowIso, state, record }, null, 2) + '\n', 'utf8')
|
|
207
|
+
return file;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read one task file.
|
|
212
|
+
* @returns { file, backedUpAt, state, raw, record } or null when the file is unreadable.
|
|
213
|
+
*
|
|
214
|
+
* A malformed file yields null rather than throwing: one bad file must not take the layer down,
|
|
215
|
+
* and the index reports what it could not read instead of pretending the task is absent.
|
|
216
|
+
*/
|
|
217
|
+
export function readTaskFile(file) {
|
|
218
|
+
try {
|
|
219
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
220
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.record || typeof parsed.record !== 'object') return null
|
|
221
|
+
return {
|
|
222
|
+
file,
|
|
223
|
+
backedUpAt: typeof parsed.backedUpAt === 'string' ? parsed.backedUpAt : null,
|
|
224
|
+
// Kept verbatim, including when unrecognised, so the index can show what it found.
|
|
225
|
+
state: typeof parsed.state === 'string' ? parsed.state : null,
|
|
226
|
+
stateKnown: readState(parsed.state) !== null,
|
|
227
|
+
record: parsed.record,
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every task file in the layer, ordered by title so the index is stable between runs.
|
|
236
|
+
* @returns { tasks, unreadable } - unreadable names the files that could not be parsed.
|
|
237
|
+
*/
|
|
238
|
+
export function listTasks(root) {
|
|
239
|
+
const dir = tasksDir(root)
|
|
240
|
+
if (!existsSync(dir)) return { tasks: [], unreadable: [] }
|
|
241
|
+
const tasks = [];
|
|
242
|
+
const unreadable = [];
|
|
243
|
+
for (const name of readdirSync(dir)) {
|
|
244
|
+
if (!name.endsWith('.json')) continue;
|
|
245
|
+
const task = readTaskFile(join(dir, name));
|
|
246
|
+
if (task === null) unreadable.push(name);
|
|
247
|
+
else tasks.push(task);
|
|
248
|
+
}
|
|
249
|
+
tasks.sort((a, b) => String(a.record.title ?? '').localeCompare(String(b.record.title ?? '')));
|
|
250
|
+
unreadable.sort();
|
|
251
|
+
return { tasks, unreadable };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** How long past its due time an armed task may sit before we call it failed. */
|
|
255
|
+
export const OVERDUE_MS = 6 * 60 * 60 * 1000
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Derive a task's state from what can actually be observed.
|
|
259
|
+
*
|
|
260
|
+
* This is the honest version of the vocabulary, and three entries are narrower than they first
|
|
261
|
+
* look, because the host does not publish what they name:
|
|
262
|
+
*
|
|
263
|
+
* - FAILED is not a status the host records. A delivery failure throws before any commit
|
|
264
|
+
* (runtime.ts:101-102), so a task failing on every scan stays 'active' with no trace. What IS
|
|
265
|
+
* observable is a task still armed long after the moment it was due, which is what this
|
|
266
|
+
* reports - 'due at X, still not delivered' rather than a claim about why.
|
|
267
|
+
* - ORPHANED is a live row whose owning session cannot be resolved, so it can never fire while
|
|
268
|
+
* still occupying the store. Detecting that needs the session lookup, which the caller does;
|
|
269
|
+
* it is passed in rather than guessed here.
|
|
270
|
+
* - SKIPPED is a live task with no backup file: seen, deliberately not captured.
|
|
271
|
+
*
|
|
272
|
+
* PAUSED outranks everything. An automatic restore must never silently re-arm something that was
|
|
273
|
+
* held back on purpose - that surprise is the thing this design exists to prevent.
|
|
274
|
+
*
|
|
275
|
+
* @returns { state, reason } - the reason is recorded so a reader is told why, not just what.
|
|
276
|
+
*/
|
|
277
|
+
export function deriveState({ stored, live, now = Date.now(), ownerResolvable = true }) {
|
|
278
|
+
if (stored === TASK_STATES.PAUSED) {
|
|
279
|
+
return { state: TASK_STATES.PAUSED, reason: 'held back on purpose; automatic restore leaves it alone' };
|
|
280
|
+
}
|
|
281
|
+
if (live) {
|
|
282
|
+
if (!ownerResolvable) {
|
|
283
|
+
return { state: TASK_STATES.ORPHANED, reason: 'armed, but the session that owns it cannot be resolved, so it can never fire' };
|
|
284
|
+
}
|
|
285
|
+
if (live.status === 'inactive') {
|
|
286
|
+
return { state: TASK_STATES.DONE, reason: 'delivered, or its recurrence is exhausted' };
|
|
287
|
+
}
|
|
288
|
+
const due = Date.parse(live.record && live.record.scheduledAt ? live.record.scheduledAt : '');
|
|
289
|
+
if (Number.isFinite(due) && due < now - OVERDUE_MS) {
|
|
290
|
+
return { state: TASK_STATES.FAILED, reason: 'was due at ' + new Date(due).toISOString() + ' and has still not delivered' };
|
|
291
|
+
}
|
|
292
|
+
return { state: TASK_STATES.ACTIVE, reason: 'armed in the live store and waiting for its next occurrence' };
|
|
293
|
+
}
|
|
294
|
+
if (stored === TASK_STATES.ACTIVE) {
|
|
295
|
+
return { state: TASK_STATES.STOPPED, reason: 'was armed; the host deletes a task when its session stops' };
|
|
296
|
+
}
|
|
297
|
+
return { state: TASK_STATES.PENDING, reason: 'in the backup, not in the live store; a restore re-arms it' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Render the layer index.
|
|
302
|
+
*
|
|
303
|
+
* Readable and diffable, which is the whole reason the tasks are files rather than one blob: a
|
|
304
|
+
* git diff of this page is how you see that a task appeared, changed state, or went missing.
|
|
305
|
+
* Unreadable files are listed rather than omitted - an index that hides what it could not parse
|
|
306
|
+
* reports a smaller world than exists.
|
|
307
|
+
*/
|
|
308
|
+
export function renderTaskIndex(rows, { unreadable = [], nowIso, error = null } = {}) {
|
|
309
|
+
const out = ['# Scheduled tasks', ''];
|
|
310
|
+
out.push('Portable backup of the DSH scheduled tasks on this machine. One file per task, plus');
|
|
311
|
+
out.push('this index. Machine-local state (the owning session, delivery history) is never stored.');
|
|
312
|
+
out.push('');
|
|
313
|
+
out.push('Generated ' + nowIso + '. ' + rows.length + ' task(s).');
|
|
314
|
+
// A page that stayed silent while the backup could not read the store would read as a healthy
|
|
315
|
+
// machine with nothing to do. The failure belongs on the page, not only in a return value.
|
|
316
|
+
if (error) {
|
|
317
|
+
out.push('');
|
|
318
|
+
out.push('> **The last backup could not read the schedule store.** ' + String(error));
|
|
319
|
+
out.push('>');
|
|
320
|
+
out.push('> The tasks below are what memory already holds; they are not a fresh reading.');
|
|
321
|
+
}
|
|
322
|
+
out.push('');
|
|
323
|
+
if (rows.length === 0) {
|
|
324
|
+
out.push('No tasks are backed up yet. The backup runs on the memory sync cadence, or on demand');
|
|
325
|
+
out.push('with memory_tasks.');
|
|
326
|
+
out.push('');
|
|
327
|
+
} else {
|
|
328
|
+
out.push('| task | kind | schedule | state | backed up |');
|
|
329
|
+
out.push('| --- | --- | --- | --- | --- |');
|
|
330
|
+
for (const row of rows) {
|
|
331
|
+
out.push('| ' + cell(row.record.title) + ' | ' + cell(row.record.kind) + ' | ' + cell(scheduleOf(row.record)) + ' | ' + cell(stateCell(row)) + ' | ' + cell(row.backedUpAt) + ' |');
|
|
332
|
+
}
|
|
333
|
+
out.push('');
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
out.push('## ' + String(row.record.title ?? '(untitled)'));
|
|
336
|
+
out.push('');
|
|
337
|
+
out.push('- file: `' + row.fileName + '`');
|
|
338
|
+
out.push('- id: `' + String(row.record.id ?? '') + '` (diagnostic only; a restore mints a new one)');
|
|
339
|
+
out.push('- state: **' + row.state + '** - ' + row.reason);
|
|
340
|
+
out.push('');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (unreadable.length > 0) {
|
|
344
|
+
out.push('## Could not be read');
|
|
345
|
+
out.push('');
|
|
346
|
+
for (const name of unreadable) out.push('- `' + name + '` - not valid task JSON, so it is neither counted nor drawn');
|
|
347
|
+
out.push('');
|
|
348
|
+
}
|
|
349
|
+
return out.join('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A markdown table cell: a pipe or a newline in a title would otherwise break the row. */
|
|
353
|
+
function cell(value) {
|
|
354
|
+
return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The schedule in words, so the index is readable without decoding the record. */
|
|
358
|
+
function scheduleOf(record) {
|
|
359
|
+
const kind = String(record.kind ?? '');
|
|
360
|
+
const zone = record.timeZone ? ' ' + record.timeZone : '';
|
|
361
|
+
if (kind === 'cron') return 'cron ' + String(record.expression ?? '') + zone;
|
|
362
|
+
if (kind === 'weekly') return 'weekly ' + JSON.stringify(record.weekly ?? {}) + zone;
|
|
363
|
+
if (kind === 'daily') return 'daily ' + JSON.stringify(record.daily ?? {}) + zone;
|
|
364
|
+
// The same camelCase/snake_case confusion as `after` above, in the same function. Writing a test
|
|
365
|
+
// to pin ONE of them surfaced the other.
|
|
366
|
+
if (kind === 'every') return 'every ' + String(record.everySeconds ?? '') + 's';
|
|
367
|
+
if (kind === 'at') return 'at ' + String(record.scheduledAt ?? '');
|
|
368
|
+
// `afterSeconds`, not `after_seconds` - the record uses camelCase, the CREATE REQUEST uses
|
|
369
|
+
// snake_case. The live index printed "after s" with the interval missing, which no test saw
|
|
370
|
+
// because no test rendered an `after` record.
|
|
371
|
+
if (kind === 'after') return 'after ' + String(record.afterSeconds ?? '') + 's';
|
|
372
|
+
return kind;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The state cell: an unrecognised stored state is shown as found, never silently defaulted. */
|
|
376
|
+
function stateCell(row) {
|
|
377
|
+
return row.stateKnown === false ? row.state + ' (unknown state in file)' : row.state;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The kinds the schedule plugin can store (types.ts ScheduleRecord union). */
|
|
381
|
+
export const KNOWN_KINDS = ['after', 'at', 'every', 'daily', 'weekly', 'cron']
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Where the schedule plugin keeps its tasks: a storage unit under the DSH home.
|
|
385
|
+
*
|
|
386
|
+
* DSH_HOME is the harness's own override (boot/app-boot reads it), defaulting to ~/.dsh - the
|
|
387
|
+
* same resolution the harness uses, so the plugin finds the store without being told.
|
|
388
|
+
*/
|
|
389
|
+
export function defaultSchedulePath(home = homedir()) {
|
|
390
|
+
const configured = process.env.DSH_HOME
|
|
391
|
+
const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
|
|
392
|
+
return join(dshHome, 'storages', 'schedule.json')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Read the live schedule store.
|
|
397
|
+
* @returns { tasks, error, missing } where tasks are the rows as stored.
|
|
398
|
+
*
|
|
399
|
+
* A missing file is not an error: a machine that has never scheduled anything has no store, and
|
|
400
|
+
* the backup pass must read that as 'nothing to do' rather than as a failure. A file that exists
|
|
401
|
+
* but cannot be read IS reported, because writing an index over an unreadable store would quietly
|
|
402
|
+
* claim the machine has no tasks.
|
|
403
|
+
*/
|
|
404
|
+
export function readScheduleStore(path) {
|
|
405
|
+
if (!existsSync(path)) return { tasks: [], error: null, missing: true }
|
|
406
|
+
let parsed
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return { tasks: [], error: 'unreadable: ' + (error && error.message ? error.message : String(error)), missing: false }
|
|
411
|
+
}
|
|
412
|
+
const table = parsed && parsed.tables && parsed.tables.tasks
|
|
413
|
+
if (!table || typeof table !== 'object') return { tasks: [], error: 'no tables.tasks in the schedule store', missing: false }
|
|
414
|
+
return { tasks: Object.keys(table).map((id) => ({ id, ...table[id] })), error: null, missing: false }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Back up every live task, and refresh the state of every task already backed up.
|
|
419
|
+
*
|
|
420
|
+
* ADDITIVE, ALWAYS. Nothing here deletes: a task gone from the live store keeps its file and has
|
|
421
|
+
* its state rewritten to say so. A stale backup must never be able to wipe a schedule, and a task
|
|
422
|
+
* deleted on purpose must stay visible as gone rather than vanish from the record of what this
|
|
423
|
+
* machine used to run.
|
|
424
|
+
*
|
|
425
|
+
* A task whose kind we do not recognise is stored and flagged SKIPPED: dropping it would mean losing
|
|
426
|
+
* a task from a backup, and flagging it means a restore refuses it rather than guessing.
|
|
427
|
+
*/
|
|
428
|
+
export function backupTasks(root, { schedulePath, now = Date.now(), nowIso, sessions } = {}) {
|
|
429
|
+
const at = nowIso ?? new Date(now).toISOString()
|
|
430
|
+
const live = readScheduleStore(schedulePath ?? defaultSchedulePath())
|
|
431
|
+
// Read once per pass, not per task: it is a directory walk, and every row asks the same question.
|
|
432
|
+
// Injectable so a test can state the world instead of inheriting this machine's: `null` means
|
|
433
|
+
// the question cannot be answered and no row is called orphaned.
|
|
434
|
+
const sessionSet = sessions === undefined ? sessionIdsOnDisk() : sessions
|
|
435
|
+
const before = listTasks(root)
|
|
436
|
+
if (live.error !== null) {
|
|
437
|
+
// The index is still rewritten, so a page from an earlier pass cannot sit on disk describing a
|
|
438
|
+
// store this pass could not read.
|
|
439
|
+
writeTaskIndex(root, before.tasks.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at, error: live.error })
|
|
440
|
+
return { written: 0, refreshed: 0, skipped: [], stopped: 0, unreadable: before.unreadable, error: live.error }
|
|
441
|
+
}
|
|
442
|
+
const byKey = new Map()
|
|
443
|
+
for (const task of before.tasks) byKey.set(contentKey(task.record), task)
|
|
444
|
+
let written = 0
|
|
445
|
+
let refreshed = 0
|
|
446
|
+
const skipped = []
|
|
447
|
+
const seen = new Set()
|
|
448
|
+
for (const task of live.tasks) {
|
|
449
|
+
const record = task.record
|
|
450
|
+
if (!record || typeof record !== 'object') { skipped.push({ id: task.id, reason: 'no record' }); continue }
|
|
451
|
+
if (!KNOWN_KINDS.includes(String(record.kind))) {
|
|
452
|
+
// CAPTURED, not dropped. An earlier version skipped these entirely, which meant a task whose
|
|
453
|
+
// kind we did not recognise silently vanished from a BACKUP - the one job this layer has. It
|
|
454
|
+
// is written and flagged 'skipped' instead, and planRestore refuses that state, so it can
|
|
455
|
+
// never be blindly re-armed. Preserved, visible, and unrestorable: all three on purpose.
|
|
456
|
+
skipped.push({ id: task.id, reason: 'unrecognised kind: ' + String(record.kind) })
|
|
457
|
+
writeTask(root, record, TASK_STATES.SKIPPED, byKey.get(contentKey(record))?.backedUpAt ?? at)
|
|
458
|
+
seen.add(contentKey(record))
|
|
459
|
+
continue
|
|
460
|
+
}
|
|
461
|
+
const key = contentKey(record)
|
|
462
|
+
seen.add(key)
|
|
463
|
+
const previous = byKey.get(key)
|
|
464
|
+
// A held-back task stays held back across every backup: that state is the user's decision, and
|
|
465
|
+
// a routine pass must not overwrite it with a derived one.
|
|
466
|
+
const held = previous && previous.stateKnown && previous.state === TASK_STATES.PAUSED
|
|
467
|
+
// Without this the ORPHANED state exists in the vocabulary and is never produced by any real
|
|
468
|
+
// pass - reachable from a unit test and from nothing else. `sessions === null` means the
|
|
469
|
+
// sessions directory is absent and we cannot tell, so the row is left as active.
|
|
470
|
+
const ownerResolvable = sessionSet === null || task.sessionId === undefined ? true : sessionSet.has(task.sessionId)
|
|
471
|
+
const derived = deriveState({ stored: held ? TASK_STATES.PAUSED : null, live: task, now, ownerResolvable })
|
|
472
|
+
writeTask(root, record, derived.state, previous ? previous.backedUpAt : at)
|
|
473
|
+
if (previous) refreshed += 1
|
|
474
|
+
else written += 1
|
|
475
|
+
}
|
|
476
|
+
let stopped = 0
|
|
477
|
+
const rows = []
|
|
478
|
+
for (const task of listTasks(root).tasks) {
|
|
479
|
+
if (seen.has(contentKey(task.record))) { rows.push(task); continue }
|
|
480
|
+
const derived = deriveState({ stored: task.stateKnown ? task.state : null, live: null, now })
|
|
481
|
+
if (derived.state !== task.state) {
|
|
482
|
+
writeTask(root, task.record, derived.state, task.backedUpAt ?? at)
|
|
483
|
+
stopped += 1
|
|
484
|
+
}
|
|
485
|
+
rows.push({ ...task, state: derived.state })
|
|
486
|
+
}
|
|
487
|
+
writeTaskIndex(root, rows.map((t) => describeTask(t, now)), { unreadable: before.unreadable, nowIso: at })
|
|
488
|
+
return { written, refreshed, skipped, stopped, unreadable: before.unreadable, error: null, rows, nowIso: at }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Write the layer index.
|
|
493
|
+
*
|
|
494
|
+
* Regenerated on every pass rather than appended: an index that only grows would report tasks the
|
|
495
|
+
* machine no longer runs as though they were still there. The per-task files are the truth; this
|
|
496
|
+
* page is a view of them, which is why losing it costs nothing.
|
|
497
|
+
* @returns the index path.
|
|
498
|
+
*/
|
|
499
|
+
export function writeTaskIndex(root, rows, { unreadable = [], nowIso, error = null } = {}) {
|
|
500
|
+
ensureTasksDir(root)
|
|
501
|
+
const file = join(tasksDir(root), TASK_INDEX)
|
|
502
|
+
writeFileSync(file, renderTaskIndex(rows, { unreadable, nowIso, error }), 'utf8')
|
|
503
|
+
return file
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Translate a stored record back into the request the schedule service accepts.
|
|
508
|
+
*
|
|
509
|
+
* The two shapes do NOT share field names, and the differences are the kind that fail silently:
|
|
510
|
+
* a record stores `timeZone` and `everySeconds` (types.ts ScheduleRecord union), while
|
|
511
|
+
* ScheduleCreateRequest takes `time_zone` and `every_seconds`. Passing the record straight
|
|
512
|
+
* through would drop the zone - a task would be re-armed in the wrong time zone, or refused, and
|
|
513
|
+
* nothing in the record would look wrong afterwards.
|
|
514
|
+
*
|
|
515
|
+
* One-shot kinds are REFUSED rather than approximated. An `at` record keeps only its resolved
|
|
516
|
+
* instant, which for any task that already ran is in the past, and an `after` record keeps a
|
|
517
|
+
* relative delay whose clock would restart from now. Re-arming either would fabricate a schedule
|
|
518
|
+
* the user never set, so the caller is told instead.
|
|
519
|
+
*
|
|
520
|
+
* @returns { request } or { error } naming exactly what could not be translated.
|
|
521
|
+
*/
|
|
522
|
+
export function toCreateRequest(record) {
|
|
523
|
+
if (!record || typeof record !== 'object') return { error: 'no record' }
|
|
524
|
+
const title = String(record.title ?? '').trim()
|
|
525
|
+
const prompt = String(record.prompt ?? '')
|
|
526
|
+
if (title === '') return { error: 'no title' }
|
|
527
|
+
if (prompt === '') return { error: 'no prompt' }
|
|
528
|
+
const kind = String(record.kind ?? '')
|
|
529
|
+
const zone = record.timeZone
|
|
530
|
+
if (kind === 'cron') {
|
|
531
|
+
if (typeof record.expression !== 'string' || record.expression === '') return { error: 'cron with no expression' }
|
|
532
|
+
if (typeof zone !== 'string' || zone === '') return { error: 'cron with no time zone' }
|
|
533
|
+
return { request: { title, prompt, cron: { expression: record.expression, time_zone: zone } } }
|
|
534
|
+
}
|
|
535
|
+
if (kind === 'daily') {
|
|
536
|
+
if (typeof record.time !== 'string' || record.time === '') return { error: 'daily with no time' }
|
|
537
|
+
if (typeof zone !== 'string' || zone === '') return { error: 'daily with no time zone' }
|
|
538
|
+
return { request: { title, prompt, daily: { time: record.time, time_zone: zone } } }
|
|
539
|
+
}
|
|
540
|
+
if (kind === 'weekly') {
|
|
541
|
+
if (typeof record.time !== 'string' || record.time === '') return { error: 'weekly with no time' }
|
|
542
|
+
if (typeof zone !== 'string' || zone === '') return { error: 'weekly with no time zone' }
|
|
543
|
+
if (!Array.isArray(record.weekdays) || record.weekdays.length === 0) return { error: 'weekly with no weekdays' }
|
|
544
|
+
return { request: { title, prompt, weekly: { time: record.time, time_zone: zone, weekdays: [...record.weekdays] } } }
|
|
545
|
+
}
|
|
546
|
+
if (kind === 'every') {
|
|
547
|
+
if (!Number.isFinite(record.everySeconds) || record.everySeconds <= 0) return { error: 'every with no interval' }
|
|
548
|
+
return { request: { title, prompt, every_seconds: record.everySeconds } }
|
|
549
|
+
}
|
|
550
|
+
if (kind === 'at' || kind === 'after') {
|
|
551
|
+
return { error: 'a one-shot ' + kind + ' task is not restorable: it either already fired or its target is in the past, and re-arming it would invent a schedule that was never set' }
|
|
552
|
+
}
|
|
553
|
+
return { error: 'unrecognised kind: ' + kind }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Decide what a restore would do, without doing any of it.
|
|
558
|
+
*
|
|
559
|
+
* Pure, so the decision can be tested and shown before anything is written. Additive and
|
|
560
|
+
* de-duplicating by CONTENT: the service mints a fresh id on every create (schedule index.ts:256),
|
|
561
|
+
* so a restored task never keeps the id it was backed up with, and matching on ids would find
|
|
562
|
+
* nothing on the second boot and re-add everything.
|
|
563
|
+
*
|
|
564
|
+
* @returns { add, present, paused, refused } - one entry per backed-up task, each saying why.
|
|
565
|
+
*/
|
|
566
|
+
export function planRestore(root, { liveTasks = [], now = Date.now() } = {}) {
|
|
567
|
+
const live = new Set(liveTasks.map((task) => contentKey(task.record)))
|
|
568
|
+
const { tasks, unreadable } = listTasks(root)
|
|
569
|
+
const add = []
|
|
570
|
+
const present = []
|
|
571
|
+
const paused = []
|
|
572
|
+
const refused = []
|
|
573
|
+
for (const task of tasks) {
|
|
574
|
+
const key = contentKey(task.record)
|
|
575
|
+
if (live.has(key)) { present.push({ task, reason: 'already in the live store, under whatever id it now carries' }); continue }
|
|
576
|
+
if (task.stateKnown && task.state === TASK_STATES.PAUSED) { paused.push({ task, reason: 'held back on purpose; automatic restore leaves it alone' }); continue }
|
|
577
|
+
if (task.stateKnown && task.state === TASK_STATES.SKIPPED) { refused.push({ task, reason: 'recorded as skipped' }); continue }
|
|
578
|
+
const translated = toCreateRequest(task.record)
|
|
579
|
+
if (translated.error) refused.push({ task, reason: translated.error })
|
|
580
|
+
else add.push({ task, request: translated.request })
|
|
581
|
+
}
|
|
582
|
+
return { add, present, paused, refused, unreadable }
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** The directory DSH keeps session records in, beside the schedule store's home. */
|
|
586
|
+
export function sessionsRoot(home = homedir()) {
|
|
587
|
+
const configured = process.env.DSH_HOME
|
|
588
|
+
const dshHome = configured && configured !== '' ? configured : join(home, '.dsh')
|
|
589
|
+
return join(dshHome, 'sessions')
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Every session id that still has a record on disk.
|
|
594
|
+
*
|
|
595
|
+
* Used to tell ORPHANED from ACTIVE. A live task is armed against the session that created it, and
|
|
596
|
+
* the runtime resolves an agent from that id before every delivery (schedule runtime.ts:101) - so
|
|
597
|
+
* a task whose session is gone is a row that will never fire while still occupying the store. The
|
|
598
|
+
* backup cannot see the runtime, but it can see whether the session still exists.
|
|
599
|
+
*
|
|
600
|
+
* @returns a Set, or null when the sessions directory is absent
|
|
601
|
+
*
|
|
602
|
+
* Null rather than an empty set on purpose: no sessions directory means we cannot tell, and an
|
|
603
|
+
* empty set would mark every task on the machine orphaned. Refusing to guess is the rule here.
|
|
604
|
+
*/
|
|
605
|
+
export function sessionIdsOnDisk(home = homedir()) {
|
|
606
|
+
const root = sessionsRoot(home)
|
|
607
|
+
if (!existsSync(root)) return null
|
|
608
|
+
const ids = new Set()
|
|
609
|
+
let projects
|
|
610
|
+
try { projects = readdirSync(root) } catch { return null }
|
|
611
|
+
for (const project of projects) {
|
|
612
|
+
let sessions
|
|
613
|
+
try { sessions = readdirSync(join(root, project)) } catch { continue }
|
|
614
|
+
for (const id of sessions) ids.add(id)
|
|
615
|
+
}
|
|
616
|
+
return ids
|
|
617
|
+
}
|