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.
- package/CHANGELOG.md +6 -4
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/cli.js +25 -10
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/improve-result.js +87 -7
- package/dist/core/state-db.js +1 -0
- package/dist/scripts/migrate-storage.js +21 -3
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +89 -8
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +4 -2
- package/docs/migration/v0.8-to-v0.9.md +64 -9
- 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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
const
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
276
|
-
|
|
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
|
|
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 =
|
|
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
|
|
479
|
-
`
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
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 (
|
|
231
|
+
if (normalized.profileFilteredRefs !== undefined && !Array.isArray(normalized.profileFilteredRefs)) {
|
|
154
232
|
fail("profileFilteredRefs must be an array");
|
|
155
233
|
}
|
|
156
234
|
return {
|
|
157
|
-
envelope:
|
|
235
|
+
envelope: normalized,
|
|
158
236
|
strategy: null,
|
|
159
|
-
legacyProfile: typeof
|
|
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)}`);
|
package/dist/core/state-db.js
CHANGED
|
@@ -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";
|