akm-cli 0.9.0-rc.3 → 0.9.0-rc.4

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 (35) hide show
  1. package/CHANGELOG.md +6 -4
  2. package/README.md +7 -8
  3. package/SECURITY.md +5 -3
  4. package/dist/akm +44 -33
  5. package/dist/akm-migrate-storage +44 -33
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/cli.js +25 -10
  8. package/dist/commands/health/html-report.js +23 -2
  9. package/dist/commands/health/improve-metrics.js +45 -6
  10. package/dist/commands/health/md-report.js +10 -1
  11. package/dist/commands/health/windows.js +1 -1
  12. package/dist/commands/health.js +1 -1
  13. package/dist/commands/tasks/default-tasks.js +5 -5
  14. package/dist/commands/tasks/tasks-cli.js +7 -3
  15. package/dist/commands/tasks/tasks.js +261 -68
  16. package/dist/core/improve-result.js +87 -7
  17. package/dist/core/state-db.js +1 -0
  18. package/dist/scripts/migrate-storage.js +21 -3
  19. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
  20. package/dist/storage/repositories/task-history-repository.js +30 -3
  21. package/dist/tasks/backends/cron.js +71 -38
  22. package/dist/tasks/backends/exec-utils.js +55 -3
  23. package/dist/tasks/backends/launchd.js +241 -50
  24. package/dist/tasks/backends/schtasks.js +434 -59
  25. package/dist/tasks/command-executable.js +93 -0
  26. package/dist/tasks/parser.js +142 -6
  27. package/dist/tasks/resolve-akm-bin.js +19 -4
  28. package/dist/tasks/runner.js +258 -93
  29. package/dist/tasks/schedule.js +108 -19
  30. package/dist/tasks/scheduler-invocation.js +86 -0
  31. package/dist/tasks/task-id.js +37 -0
  32. package/docs/README.md +103 -0
  33. package/docs/migration/release-notes/0.9.0.md +4 -2
  34. package/docs/migration/v0.8-to-v0.9.md +64 -9
  35. package/package.json +2 -3
@@ -19,13 +19,15 @@ import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
19
19
  import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
20
20
  import { resolveAssetPath } from "../../sources/resolve.js";
21
21
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
22
+ import { findBareAkmExecutableIndex } from "../../tasks/command-executable.js";
22
23
  import { parseTaskDocument } from "../../tasks/parser.js";
23
24
  import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
24
- import { exitCodeForStatus, readTaskHistory, runTask } from "../../tasks/runner.js";
25
+ import { exitCodeForStatus, INVALID_TASK_ATTEMPT_ID, readTaskHistory, recordTaskAttemptFailure, runTask, } from "../../tasks/runner.js";
25
26
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT, translateToCron } from "../../tasks/schedule.js";
27
+ import { normaliseTaskId } from "../../tasks/task-id.js";
26
28
  import { validateTaskDocument } from "../../tasks/validator.js";
27
29
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
28
- export async function akmTasksAdd(input) {
30
+ export async function akmTasksAdd(input, deps = {}) {
29
31
  const id = normaliseTaskId(input.id);
30
32
  const hasCommand = input.command !== undefined &&
31
33
  input.command !== null &&
@@ -47,7 +49,6 @@ export async function akmTasksAdd(input) {
47
49
  const target = resolveTaskWriteTarget();
48
50
  const stashDir = target.source.path;
49
51
  const typeRoot = path.join(stashDir, "tasks");
50
- fs.mkdirSync(typeRoot, { recursive: true });
51
52
  const assetPath = resolveAssetPathFromName("task", typeRoot, id);
52
53
  if (!isWithin(assetPath, typeRoot)) {
53
54
  throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
@@ -73,25 +74,103 @@ export async function akmTasksAdd(input) {
73
74
  });
74
75
  const task = parseTaskDocument({ yaml, filePath: assetPath, id });
75
76
  await validateTaskDocument(task, { backend, stashDir });
77
+ const obsoleteReason = obsoleteBackupTaskReason(task);
78
+ if (obsoleteReason)
79
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
76
80
  const ref = taskAssetRef(id);
77
- await writeAssetToSource(target.source, target.config, ref, yaml);
78
- // Install in the OS scheduler. If install fails after the file was written,
79
- // delete the file so the on-disk state never claims a task is registered
80
- // when it isn't.
81
+ const previousYaml = fs.existsSync(assetPath) ? fs.readFileSync(assetPath, "utf8") : undefined;
82
+ let previousTask;
83
+ let previousTaskError;
84
+ if (previousYaml !== undefined) {
85
+ try {
86
+ previousTask = parseTaskDocument({ yaml: previousYaml, filePath: assetPath, id });
87
+ }
88
+ catch (err) {
89
+ previousTaskError = err;
90
+ }
91
+ }
92
+ const sched = deps.backend ?? selectBackend();
93
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
94
+ const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
95
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
96
+ const wasInstalled = previousYaml !== undefined && (await sched.list()).some((entry) => entry.id === id);
97
+ let sourceRestoreArmed = false;
98
+ let installSucceeded = false;
81
99
  try {
82
- const sched = selectBackend();
100
+ sourceRestoreArmed = true;
101
+ await writeAsset(target.source, target.config, ref, yaml);
83
102
  await sched.install(task);
103
+ installSucceeded = true;
104
+ commitBoundary(target, `Update task:${id}`);
84
105
  }
85
106
  catch (err) {
86
- try {
87
- await deleteAssetFromSource(target.source, target.config, ref);
107
+ const rollbackErrors = [];
108
+ let sourceRestored = false;
109
+ if (sourceRestoreArmed) {
110
+ try {
111
+ if (previousYaml === undefined) {
112
+ if (fs.existsSync(assetPath)) {
113
+ await deleteAsset(target.source, target.config, ref);
114
+ sourceRestored = true;
115
+ }
116
+ }
117
+ else {
118
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, assetPath, previousYaml);
119
+ sourceRestored = true;
120
+ }
121
+ }
122
+ catch (rollbackError) {
123
+ rollbackErrors.push(rollbackError);
124
+ }
88
125
  }
89
- catch {
90
- /* ignore */
126
+ if (installSucceeded && !wasInstalled) {
127
+ try {
128
+ await sched.uninstall(id);
129
+ }
130
+ catch (rollbackError) {
131
+ rollbackErrors.push(rollbackError);
132
+ }
133
+ }
134
+ else if (installSucceeded && previousTask) {
135
+ try {
136
+ await sched.install(previousTask);
137
+ }
138
+ catch (rollbackError) {
139
+ rollbackErrors.push(rollbackError);
140
+ try {
141
+ if (typeof sched.setEnabled !== "function") {
142
+ throw new Error(`Scheduler backend "${sched.name}" cannot disable task "${id}".`);
143
+ }
144
+ await sched.setEnabled(id, false);
145
+ }
146
+ catch (disableError) {
147
+ rollbackErrors.push(disableError);
148
+ try {
149
+ await sched.uninstall(id);
150
+ }
151
+ catch (uninstallError) {
152
+ rollbackErrors.push(uninstallError);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ else if (installSucceeded && wasInstalled) {
158
+ rollbackErrors.push(previousTaskError ?? new Error(`Prior task "${id}" could not be restored.`));
159
+ }
160
+ if (sourceRestored) {
161
+ try {
162
+ commitBoundary(target, `Restore task:${id}`);
163
+ }
164
+ catch (rollbackError) {
165
+ rollbackErrors.push(rollbackError);
166
+ }
167
+ }
168
+ if (rollbackErrors.length > 0) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${id}" was incomplete.`);
91
171
  }
92
172
  throw err;
93
173
  }
94
- commitWriteTargetBoundary(target, `Update task:${id}`);
95
174
  return {
96
175
  id,
97
176
  ref: `task:${id}`,
@@ -206,38 +285,68 @@ export async function akmTasksShow(id) {
206
285
  tags: task.tags,
207
286
  };
208
287
  }
209
- export async function akmTasksRemove(id) {
288
+ export async function akmTasksRemove(id, deps = {}) {
210
289
  const normalised = normaliseTaskId(id);
211
290
  const target = resolveTaskWriteTarget();
212
291
  const stashDir = target.source.path;
213
292
  const typeRoot = path.join(stashDir, "tasks");
214
293
  if (fs.existsSync(typeRoot))
215
294
  warnLegacyMdTaskFiles(typeRoot);
216
- await resolveAssetPath(stashDir, "task", normalised);
295
+ const filePath = await resolveAssetPath(stashDir, "task", normalised);
296
+ const yaml = fs.readFileSync(filePath, "utf8");
217
297
  const ref = taskAssetRef(normalised);
218
- const sched = selectBackend();
219
- let uninstallError;
220
- let deleteError;
298
+ const sched = deps.backend ?? selectBackend();
299
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
300
+ const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
301
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
302
+ const wasInstalled = (await sched.list()).some((entry) => entry.id === normalised);
303
+ const previousTask = wasInstalled ? parseTaskDocument({ yaml, filePath, id: normalised }) : undefined;
304
+ let uninstallAttempted = false;
305
+ let deleteAttempted = false;
221
306
  try {
307
+ uninstallAttempted = true;
222
308
  await sched.uninstall(normalised);
309
+ deleteAttempted = true;
310
+ await deleteAsset(target.source, target.config, ref);
311
+ commitBoundary(target, `Remove task:${normalised}`);
223
312
  }
224
313
  catch (err) {
225
- uninstallError = err;
226
- }
227
- try {
228
- await deleteAssetFromSource(target.source, target.config, ref);
229
- }
230
- catch (err) {
231
- deleteError = err;
314
+ const rollbackErrors = [];
315
+ let sourceRestored = false;
316
+ if (deleteAttempted) {
317
+ try {
318
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, filePath, yaml);
319
+ sourceRestored = true;
320
+ }
321
+ catch (rollbackError) {
322
+ rollbackErrors.push(rollbackError);
323
+ }
324
+ }
325
+ if (uninstallAttempted && previousTask) {
326
+ try {
327
+ await sched.install(previousTask);
328
+ }
329
+ catch (rollbackError) {
330
+ rollbackErrors.push(rollbackError);
331
+ }
332
+ }
333
+ if (sourceRestored) {
334
+ try {
335
+ commitBoundary(target, `Restore task:${normalised}`);
336
+ }
337
+ catch (rollbackError) {
338
+ rollbackErrors.push(rollbackError);
339
+ }
340
+ }
341
+ if (rollbackErrors.length > 0) {
342
+ const message = err instanceof Error ? err.message : String(err);
343
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${normalised}" was incomplete.`);
344
+ }
345
+ throw err;
232
346
  }
233
- if (uninstallError !== undefined)
234
- throw uninstallError;
235
- if (deleteError !== undefined)
236
- throw deleteError;
237
- commitWriteTargetBoundary(target, `Remove task:${normalised}`);
238
347
  return { id: normalised, removed: true, backend: sched.name };
239
348
  }
240
- export async function akmTasksSetEnabled(id, enabled) {
349
+ export async function akmTasksSetEnabled(id, enabled, deps = {}) {
241
350
  const normalised = normaliseTaskId(id);
242
351
  const target = resolveTaskWriteTarget();
243
352
  const stashDir = target.source.path;
@@ -246,42 +355,111 @@ export async function akmTasksSetEnabled(id, enabled) {
246
355
  warnLegacyMdTaskFiles(typeRoot);
247
356
  const filePath = await resolveAssetPath(stashDir, "task", normalised);
248
357
  const yaml = fs.readFileSync(filePath, "utf8");
249
- // Parse before writing so stale v1 tasks are diagnosed without changing the
250
- // source file or its installed scheduler entry.
251
- parseTaskDocument({ yaml, filePath, id: normalised });
358
+ // Parse before writing so unsupported tasks are diagnosed without changing
359
+ // the source file or its installed scheduler entry.
360
+ const previousTask = parseTaskDocument({ yaml, filePath, id: normalised });
252
361
  const updated = setEnabledInYaml(yaml, enabled);
362
+ const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
363
+ const obsoleteReason = obsoleteBackupTaskReason(task);
364
+ if (obsoleteReason)
365
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
253
366
  const ref = taskAssetRef(normalised);
254
- await writeAssetToSource(target.source, target.config, ref, updated);
255
- const sched = selectBackend();
367
+ const sched = deps.backend ?? selectBackend();
368
+ const writeAsset = deps.writeAsset ?? writeAssetToSource;
369
+ const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
370
+ const wasInstalled = (await sched.list()).some((entry) => entry.id === normalised);
371
+ let sourceRestoreArmed = false;
372
+ let installSucceeded = false;
256
373
  try {
374
+ sourceRestoreArmed = true;
375
+ await writeAsset(target.source, target.config, ref, updated);
257
376
  // Reinstall from the (just-updated) definition rather than only toggling
258
377
  // the comment. A plain toggle leaves a stale schedule in place if the
259
378
  // .yml's `schedule:` changed while the task was disabled — re-enabling
260
379
  // would silently keep the old cron line. install() renders the block with
261
380
  // both the current schedule and the new enabled state, and is idempotent.
262
- const task = parseTaskDocument({ yaml: updated, filePath, id: normalised });
263
381
  await sched.install(task);
382
+ installSucceeded = true;
383
+ commitBoundary(target, `Update task:${normalised}`);
264
384
  }
265
385
  catch (err) {
266
- // Roll the file back so the YAML source-of-truth and the OS
267
- // scheduler don't diverge silently when the backend call fails.
268
- await writeAssetToSource(target.source, target.config, ref, yaml);
386
+ const rollbackErrors = [];
387
+ let sourceRestored = false;
388
+ if (sourceRestoreArmed) {
389
+ try {
390
+ await restoreTaskSourceBytes(writeAsset, target.source, target.config, ref, filePath, yaml);
391
+ sourceRestored = true;
392
+ }
393
+ catch (rollbackError) {
394
+ rollbackErrors.push(rollbackError);
395
+ }
396
+ }
397
+ if (installSucceeded) {
398
+ try {
399
+ if (wasInstalled)
400
+ await sched.install(previousTask);
401
+ else
402
+ await sched.uninstall(normalised);
403
+ }
404
+ catch (rollbackError) {
405
+ rollbackErrors.push(rollbackError);
406
+ }
407
+ }
408
+ if (sourceRestored) {
409
+ try {
410
+ commitBoundary(target, `Restore task:${normalised}`);
411
+ }
412
+ catch (rollbackError) {
413
+ rollbackErrors.push(rollbackError);
414
+ }
415
+ }
416
+ if (rollbackErrors.length > 0) {
417
+ const message = err instanceof Error ? err.message : String(err);
418
+ throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${normalised}" was incomplete.`);
419
+ }
269
420
  throw err;
270
421
  }
271
- commitWriteTargetBoundary(target, `Update task:${normalised}`);
272
422
  return { id: normalised, enabled, backend: sched.name };
273
423
  }
274
- export async function akmTasksRun(id) {
275
- const normalised = normaliseTaskId(id);
276
- const stashDir = resolveStashDir();
424
+ export async function akmTasksRun(id, options = {}) {
425
+ const startedAt = new Date();
426
+ let normalised;
427
+ try {
428
+ normalised = parseTaskRef(id).id;
429
+ }
430
+ catch (failure) {
431
+ recordTaskAttemptFailure({
432
+ taskId: INVALID_TASK_ATTEMPT_ID,
433
+ reason: "invalid_task_id",
434
+ failure,
435
+ startedAt,
436
+ });
437
+ throw failure;
438
+ }
439
+ let stashDir;
440
+ try {
441
+ stashDir = resolveStashDir();
442
+ }
443
+ catch (failure) {
444
+ recordTaskAttemptFailure({
445
+ taskId: normalised,
446
+ reason: "task_load_failed",
447
+ failure,
448
+ startedAt,
449
+ });
450
+ throw failure;
451
+ }
277
452
  const typeRoot = path.join(stashDir, "tasks");
278
453
  if (fs.existsSync(typeRoot))
279
454
  warnLegacyMdTaskFiles(typeRoot);
280
- const result = await runTask(normalised);
455
+ const result = await runTask(normalised, { stashDir, scheduled: options.scheduled === true });
456
+ const exitCode = result.status === "failed" && result.target.kind === "command" && result.detail?.exitCode === 78
457
+ ? 78
458
+ : exitCodeForStatus(result.status);
281
459
  return {
282
460
  ok: result.status === "completed" || result.status === "disabled",
283
461
  result,
284
- exitCode: exitCodeForStatus(result.status),
462
+ exitCode,
285
463
  };
286
464
  }
287
465
  export async function akmTasksHistory(input) {
@@ -314,7 +492,7 @@ export async function akmTasksSync(deps = {}) {
314
492
  .map((f) => f.slice(0, -4))
315
493
  : [];
316
494
  const sched = deps.backend ?? selectBackend();
317
- const backend = backendNameForPlatform();
495
+ const backend = sched.name;
318
496
  // Map id → installed signature so sync can detect schedule/enabled drift on
319
497
  // tasks that already exist in the scheduler, not just presence/absence.
320
498
  const present = new Map((await sched.list()).map((t) => [t.id, t.signature]));
@@ -327,16 +505,26 @@ export async function akmTasksSync(deps = {}) {
327
505
  let task;
328
506
  try {
329
507
  task = parseTaskDocument({ yaml: fs.readFileSync(filePath, "utf8"), filePath, id });
330
- }
331
- catch (err) {
332
- skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
333
- continue;
334
- }
335
- try {
336
508
  await validateTaskDocument(task, { backend, stashDir });
509
+ const obsoleteReason = obsoleteBackupTaskReason(task);
510
+ if (obsoleteReason)
511
+ throw new UsageError(obsoleteReason, "INVALID_FLAG_VALUE");
337
512
  }
338
513
  catch (err) {
339
514
  skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
515
+ if (present.has(id)) {
516
+ try {
517
+ await sched.setEnabled(id, false);
518
+ }
519
+ catch (disableError) {
520
+ try {
521
+ await sched.uninstall(id);
522
+ }
523
+ catch (uninstallError) {
524
+ throw new AggregateError([err, disableError, uninstallError], `Task "${id}" is invalid and its installed scheduler entry could not be disabled or removed.`);
525
+ }
526
+ }
527
+ }
340
528
  continue;
341
529
  }
342
530
  if (!present.has(id)) {
@@ -368,6 +556,19 @@ export async function akmTasksSync(deps = {}) {
368
556
  }
369
557
  return { installed, updated, removed, unchanged, skipped, backend: sched.name };
370
558
  }
559
+ function obsoleteBackupTaskReason(task) {
560
+ if (!task.enabled || task.target.kind !== "command")
561
+ return undefined;
562
+ const executableIndex = findBareAkmExecutableIndex(task.target.cmd);
563
+ if (executableIndex === undefined)
564
+ return undefined;
565
+ const args = task.target.cmd.slice(executableIndex + 1);
566
+ if (args.length !== 2 || args[0] !== "db" || args[1] !== "backups")
567
+ return undefined;
568
+ return (`Task "${task.id}" invokes obsolete \`akm db backups\`, which only listed legacy backups and is not a 0.9 backup command. ` +
569
+ "AKM will not install or enable it; sync will keep any existing scheduler entry disabled, and the task file is unchanged. " +
570
+ "Use `akm backup create --for 0.9.0` for an explicit migration recovery snapshot.");
571
+ }
371
572
  export async function akmTasksDoctor() {
372
573
  const warnings = [];
373
574
  let invocation = { argv: [], via: "unresolved" };
@@ -417,22 +618,14 @@ export async function akmTasksDoctor() {
417
618
  };
418
619
  }
419
620
  // ── helpers ─────────────────────────────────────────────────────────────────
420
- const VALID_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
421
- function normaliseTaskId(raw) {
422
- // Accept both .yml and .md suffixes from users so muscle memory from the
423
- // pre-0.8.0 markdown task format doesn't produce a confusing "task not found".
424
- const id = raw.trim().replace(/\.(yml|md)$/, "");
425
- if (!id) {
426
- throw new UsageError("Task id must be non-empty.", "MISSING_REQUIRED_ARGUMENT");
427
- }
428
- if (!VALID_ID_RE.test(id)) {
429
- throw new UsageError(`Task id "${id}" is invalid. Use letters, digits, dots, underscores, and dashes only.`, "INVALID_FLAG_VALUE");
430
- }
431
- return id;
432
- }
433
621
  function taskAssetRef(id) {
434
622
  return { type: "task", name: id };
435
623
  }
624
+ async function restoreTaskSourceBytes(writeAsset, source, config, ref, filePath, yaml) {
625
+ await writeAsset(source, config, ref, yaml);
626
+ // The normal write path adds a trailing newline; rollback restores the raw snapshot exactly.
627
+ fs.writeFileSync(filePath, yaml, "utf8");
628
+ }
436
629
  function resolveTaskWriteTarget() {
437
630
  return resolveWriteTarget(loadConfig());
438
631
  }
@@ -475,8 +668,8 @@ function isStaleTaskError(err) {
475
668
  return err instanceof UsageError && err.code === "TASK_SCHEMA_VERSION_UNSUPPORTED";
476
669
  }
477
670
  function warnStaleTaskFiles(ids) {
478
- process.stderr.write(`WARNING: ${ids.length} task file(s) use retired task schema v1 and were not loaded.\n` +
479
- ` Rewrite them with version: 2 and replace profile with engine. See docs/migration/v0.8-to-v0.9.md#task-assets.\n` +
671
+ process.stderr.write(`WARNING: ${ids.length} task file(s) use an unsupported task schema version and were not loaded.\n` +
672
+ ` Use version: 2. See docs/migration/v0.8-to-v0.9.md#engine-and-task-assets.\n` +
480
673
  ` Affected: ${ids.map((id) => `tasks/${id}.yml`).join(", ")}\n`);
481
674
  }
482
675
  function collectStaleTaskIds() {
@@ -44,8 +44,26 @@ const COMMON_FIELDS = [
44
44
  "sync",
45
45
  "terminated",
46
46
  ];
47
- const V1_FIELDS = new Set([...COMMON_FIELDS, "profile", "profileFilteredRefs"]);
47
+ const V1_FIELDS = new Set([...COMMON_FIELDS, "profile", "profileFilteredRefs", "stalenessDetection"]);
48
48
  const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
49
+ const STALENESS_DETECTION_FIELDS = new Set([
50
+ "considered",
51
+ "deprecated",
52
+ "confirmed",
53
+ "skipped",
54
+ "durationMs",
55
+ "warnings",
56
+ ]);
57
+ const INTERRUPTED_V1_FIELDS = new Set([
58
+ "schemaVersion",
59
+ "ok",
60
+ "profile",
61
+ "scope",
62
+ "dryRun",
63
+ "plannedRefs",
64
+ "actions",
65
+ "terminated",
66
+ ]);
49
67
  function fail(message) {
50
68
  throw new Error(`invalid improve-result envelope: ${message}`);
51
69
  }
@@ -57,6 +75,33 @@ function requireExactFields(value, allowed) {
57
75
  if (unknown.length > 0)
58
76
  fail(`unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.sort().join(", ")}`);
59
77
  }
78
+ function isKnownInterruptedV1Partial(value) {
79
+ if (Object.keys(value).some((key) => !INTERRUPTED_V1_FIELDS.has(key)))
80
+ return false;
81
+ if (value.ok !== false || typeof value.dryRun !== "boolean")
82
+ return false;
83
+ if (value.profile !== undefined && typeof value.profile !== "string")
84
+ return false;
85
+ if (!Array.isArray(value.plannedRefs) || value.plannedRefs.length !== 0)
86
+ return false;
87
+ if (!Array.isArray(value.actions) || value.actions.length !== 0)
88
+ return false;
89
+ if (!isRecord(value.scope))
90
+ return false;
91
+ if (Object.keys(value.scope).some((key) => key !== "mode" && key !== "value"))
92
+ return false;
93
+ if (value.scope.mode !== "all" && value.scope.mode !== "type" && value.scope.mode !== "ref")
94
+ return false;
95
+ if (value.scope.value !== undefined && typeof value.scope.value !== "string")
96
+ return false;
97
+ if (!isRecord(value.terminated))
98
+ return false;
99
+ if (Object.keys(value.terminated).some((key) => !["reason", "at", "errorMessage"].includes(key)))
100
+ return false;
101
+ if (typeof value.terminated.reason !== "string" || typeof value.terminated.at !== "string")
102
+ return false;
103
+ return value.terminated.errorMessage === undefined || typeof value.terminated.errorMessage === "string";
104
+ }
60
105
  function validateCommon(value) {
61
106
  if (typeof value.ok !== "boolean")
62
107
  fail("ok must be a boolean");
@@ -131,6 +176,32 @@ function validateCommon(value) {
131
176
  if (value[field] !== undefined && !isRecord(value[field]))
132
177
  fail(`${field} must be an object`);
133
178
  }
179
+ if (isRecord(value.terminated)) {
180
+ requireExactFields(value.terminated, new Set(["reason", "at", "errorMessage"]));
181
+ if (typeof value.terminated.reason !== "string" || typeof value.terminated.at !== "string") {
182
+ fail("terminated.reason and terminated.at must be strings");
183
+ }
184
+ if (value.terminated.errorMessage !== undefined && typeof value.terminated.errorMessage !== "string") {
185
+ fail("terminated.errorMessage must be a string when present");
186
+ }
187
+ }
188
+ }
189
+ function validateV1StalenessDetection(value) {
190
+ if (value === undefined)
191
+ return;
192
+ if (!isRecord(value))
193
+ fail("stalenessDetection must be an object");
194
+ const unknown = Object.keys(value).filter((key) => !STALENESS_DETECTION_FIELDS.has(key));
195
+ if (unknown.length > 0) {
196
+ fail(`stalenessDetection has unknown field${unknown.length === 1 ? "" : "s"}: ${unknown.sort().join(", ")}`);
197
+ }
198
+ for (const field of ["considered", "deprecated", "confirmed", "skipped", "durationMs"]) {
199
+ if (typeof value[field] !== "number")
200
+ fail(`stalenessDetection.${field} must be a number`);
201
+ }
202
+ if (!Array.isArray(value.warnings) || value.warnings.some((warning) => typeof warning !== "string")) {
203
+ fail("stalenessDetection.warnings must be an array of strings");
204
+ }
134
205
  }
135
206
  /** Decode the persisted public v1/v2 contract without guessing across versions. */
136
207
  export function decodeImproveResult(input) {
@@ -146,17 +217,25 @@ export function decodeImproveResult(input) {
146
217
  if (!isRecord(parsed))
147
218
  fail("root must be an object");
148
219
  if (parsed.schemaVersion === 1) {
149
- requireExactFields(parsed, V1_FIELDS);
150
- validateCommon(parsed);
151
- if (parsed.profile !== undefined && typeof parsed.profile !== "string")
220
+ let normalized = parsed;
221
+ let normalizedLegacyPartial = false;
222
+ if (normalized.memorySummary === undefined && isKnownInterruptedV1Partial(normalized)) {
223
+ normalized = { ...normalized, memorySummary: { eligible: 0, derived: 0 } };
224
+ normalizedLegacyPartial = true;
225
+ }
226
+ requireExactFields(normalized, V1_FIELDS);
227
+ validateCommon(normalized);
228
+ validateV1StalenessDetection(normalized.stalenessDetection);
229
+ if (normalized.profile !== undefined && typeof normalized.profile !== "string")
152
230
  fail("profile must be a string");
153
- if (parsed.profileFilteredRefs !== undefined && !Array.isArray(parsed.profileFilteredRefs)) {
231
+ if (normalized.profileFilteredRefs !== undefined && !Array.isArray(normalized.profileFilteredRefs)) {
154
232
  fail("profileFilteredRefs must be an array");
155
233
  }
156
234
  return {
157
- envelope: parsed,
235
+ envelope: normalized,
158
236
  strategy: null,
159
- legacyProfile: typeof parsed.profile === "string" ? parsed.profile : null,
237
+ legacyProfile: typeof normalized.profile === "string" ? normalized.profile : null,
238
+ normalizedLegacyPartial,
160
239
  };
161
240
  }
162
241
  if (parsed.schemaVersion === 2) {
@@ -172,6 +251,7 @@ export function decodeImproveResult(input) {
172
251
  envelope: parsed,
173
252
  strategy: parsed.strategy,
174
253
  legacyProfile: null,
254
+ normalizedLegacyPartial: false,
175
255
  };
176
256
  }
177
257
  fail(`unsupported schemaVersion: ${String(parsed.schemaVersion)}`);
@@ -115,6 +115,7 @@ export function openStateDatabase(dbPath) {
115
115
  if (existed) {
116
116
  const preflight = openDatabase(resolvedPath, { readonly: true });
117
117
  try {
118
+ preflight.exec("PRAGMA busy_timeout = 30000");
118
119
  if (isCanonical)
119
120
  assertCurrentMigrationLedger(preflight, STATE_MIGRATIONS);
120
121
  else
@@ -17737,6 +17737,7 @@ function openStateDatabase(dbPath) {
17737
17737
  if (existed) {
17738
17738
  const preflight = openDatabase(resolvedPath, { readonly: true });
17739
17739
  try {
17740
+ preflight.exec("PRAGMA busy_timeout = 30000");
17740
17741
  if (isCanonical)
17741
17742
  assertCurrentMigrationLedger(preflight, STATE_MIGRATIONS);
17742
17743
  else
@@ -17993,10 +17994,12 @@ var init_events_repository = __esm(() => {
17993
17994
  var exports_task_history_repository = {};
17994
17995
  __export(exports_task_history_repository, {
17995
17996
  upsertTaskHistory: () => upsertTaskHistory,
17997
+ reserveTaskHistoryAttempt: () => reserveTaskHistoryAttempt,
17996
17998
  queryTaskHistory: () => queryTaskHistory,
17997
17999
  queryCompletedTaskIntervals: () => queryCompletedTaskIntervals,
17998
18000
  getTaskHistoryRuns: () => getTaskHistoryRuns,
17999
18001
  getTaskHistory: () => getTaskHistory,
18002
+ finalizeTaskHistoryAttempt: () => finalizeTaskHistoryAttempt,
18000
18003
  decodeTaskHistoryMetadata: () => decodeTaskHistoryMetadata
18001
18004
  });
18002
18005
  function metadataError(message) {
@@ -18075,6 +18078,21 @@ function decodeTaskHistoryMetadata(input) {
18075
18078
  }
18076
18079
  metadataError(`unsupported metadataVersion: ${String(version)}`);
18077
18080
  }
18081
+ function reserveTaskHistoryAttempt(db, row) {
18082
+ const result = db.prepare(`INSERT OR IGNORE INTO task_history
18083
+ (task_id, status, started_at, completed_at, failed_at, log_path,
18084
+ target_kind, target_ref, metadata_json)
18085
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json);
18086
+ return Number(result.changes) === 1;
18087
+ }
18088
+ function finalizeTaskHistoryAttempt(db, row) {
18089
+ const result = db.prepare(`UPDATE task_history
18090
+ SET status = ?, completed_at = ?, failed_at = ?, log_path = ?,
18091
+ target_kind = ?, target_ref = ?, metadata_json = ?
18092
+ WHERE task_id = ? AND started_at = ?
18093
+ AND status = 'active' AND completed_at IS NULL`).run(row.status, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json, row.task_id, row.started_at);
18094
+ return Number(result.changes) === 1;
18095
+ }
18078
18096
  function upsertTaskHistory(db, row) {
18079
18097
  db.prepare(`
18080
18098
  INSERT OR IGNORE INTO task_history
@@ -18086,12 +18104,12 @@ function upsertTaskHistory(db, row) {
18086
18104
  function getTaskHistory(db, taskId) {
18087
18105
  return db.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
18088
18106
  target_kind, target_ref, metadata_json
18089
- FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`).get(taskId);
18107
+ FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`).get(taskId);
18090
18108
  }
18091
18109
  function getTaskHistoryRuns(db, taskId, limit = 50) {
18092
18110
  return db.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
18093
18111
  target_kind, target_ref, metadata_json
18094
- FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`).all(taskId, limit);
18112
+ FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT ?`).all(taskId, limit);
18095
18113
  }
18096
18114
  function queryTaskHistory(db, options = {}) {
18097
18115
  const conditions = [];
@@ -18119,7 +18137,7 @@ function queryTaskHistory(db, options = {}) {
18119
18137
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
18120
18138
  return db.prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
18121
18139
  target_kind, target_ref, metadata_json
18122
- FROM task_history ${where} ORDER BY started_at DESC`).all(...params);
18140
+ FROM task_history ${where} ORDER BY started_at DESC, id DESC`).all(...params);
18123
18141
  }
18124
18142
  function queryCompletedTaskIntervals(db, since, until) {
18125
18143
  const sql = until ? "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND started_at < ? AND completed_at IS NOT NULL ORDER BY started_at" : "SELECT started_at, completed_at FROM task_history WHERE task_id = 'akm-improve' AND started_at >= ? AND completed_at IS NOT NULL ORDER BY started_at";