@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.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. 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
+ }