@hasna/todos 0.15.19 → 0.15.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cloud-router.d.ts +20 -1
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/query-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-commands.d.ts.map +1 -1
- package/dist/cli/index.js +851 -47
- package/dist/cli/stage-a.d.ts +21 -10
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/contracts.js +228 -16
- package/dist/db/audit.d.ts +8 -0
- package/dist/db/audit.d.ts.map +1 -1
- package/dist/db/task-lifecycle.d.ts +7 -1
- package/dist/db/task-lifecycle.d.ts.map +1 -1
- package/dist/db/tasks.d.ts +1 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/index.js +479 -20
- package/dist/lib/cli-help.d.ts +3 -2
- package/dist/lib/cli-help.d.ts.map +1 -1
- package/dist/lib/stale-lock-handoff.d.ts +25 -0
- package/dist/lib/stale-lock-handoff.d.ts.map +1 -0
- package/dist/mcp/index.js +702 -36
- package/dist/mcp.js +3 -1
- package/dist/project-registration.js +1147 -686
- package/dist/registry.js +228 -16
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.js +7 -0
- package/dist/sdk/v1.generated.d.ts +27 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1035 -369
- package/dist/server/openapi.d.ts +182 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/audit-history-import.d.ts +14 -0
- package/dist/storage/audit-history-import.d.ts.map +1 -0
- package/dist/storage/interfaces.d.ts +12 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.js +476 -19
- package/dist/task-manifest.js +11 -1
- package/dist/types/index.d.ts +43 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +3 -1
package/dist/storage.js
CHANGED
|
@@ -276,7 +276,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
276
276
|
function isTerminalStatus(status) {
|
|
277
277
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
278
278
|
}
|
|
279
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
279
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
280
280
|
var init_types = __esm(() => {
|
|
281
281
|
TASK_STATUSES = [
|
|
282
282
|
"pending",
|
|
@@ -399,6 +399,16 @@ var init_types = __esm(() => {
|
|
|
399
399
|
this.name = "LockError";
|
|
400
400
|
}
|
|
401
401
|
};
|
|
402
|
+
StaleLockHandoffError = class StaleLockHandoffError extends Error {
|
|
403
|
+
code;
|
|
404
|
+
details;
|
|
405
|
+
constructor(code, message, details = {}) {
|
|
406
|
+
super(message);
|
|
407
|
+
this.code = code;
|
|
408
|
+
this.details = details;
|
|
409
|
+
this.name = "StaleLockHandoffError";
|
|
410
|
+
}
|
|
411
|
+
};
|
|
402
412
|
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
403
413
|
agentId;
|
|
404
414
|
static code = "AGENT_NOT_FOUND";
|
|
@@ -7503,28 +7513,55 @@ var init_activity_audit = __esm(() => {
|
|
|
7503
7513
|
function sanitizeHistoryValue(value, context) {
|
|
7504
7514
|
return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
|
|
7505
7515
|
}
|
|
7506
|
-
function
|
|
7516
|
+
function insertTaskHistory(entry, db) {
|
|
7507
7517
|
const d = db || getDatabase();
|
|
7508
|
-
const
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7518
|
+
const safeEntry = {
|
|
7519
|
+
...entry,
|
|
7520
|
+
field: entry.field || null,
|
|
7521
|
+
old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
|
|
7522
|
+
new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
|
|
7523
|
+
agent_id: entry.agent_id || null,
|
|
7524
|
+
machine_id: entry.machine_id ?? currentStorageMachineId(d)
|
|
7525
|
+
};
|
|
7513
7526
|
d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
|
|
7514
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
7527
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
7528
|
+
safeEntry.id,
|
|
7529
|
+
safeEntry.task_id,
|
|
7530
|
+
safeEntry.action,
|
|
7531
|
+
safeEntry.field,
|
|
7532
|
+
safeEntry.old_value,
|
|
7533
|
+
safeEntry.new_value,
|
|
7534
|
+
safeEntry.agent_id,
|
|
7535
|
+
safeEntry.created_at,
|
|
7536
|
+
safeEntry.machine_id ?? null
|
|
7537
|
+
]);
|
|
7515
7538
|
try {
|
|
7516
7539
|
const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
|
|
7517
7540
|
logActivity2({
|
|
7518
7541
|
entity_type: "task",
|
|
7519
|
-
entity_id:
|
|
7520
|
-
action,
|
|
7521
|
-
field,
|
|
7522
|
-
old_value:
|
|
7523
|
-
new_value:
|
|
7524
|
-
actor_id:
|
|
7542
|
+
entity_id: safeEntry.task_id,
|
|
7543
|
+
action: safeEntry.action,
|
|
7544
|
+
field: safeEntry.field ?? undefined,
|
|
7545
|
+
old_value: safeEntry.old_value,
|
|
7546
|
+
new_value: safeEntry.new_value,
|
|
7547
|
+
actor_id: safeEntry.agent_id ?? undefined
|
|
7525
7548
|
}, d);
|
|
7526
7549
|
} catch {}
|
|
7527
|
-
return
|
|
7550
|
+
return safeEntry;
|
|
7551
|
+
}
|
|
7552
|
+
function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
|
|
7553
|
+
const d = db || getDatabase();
|
|
7554
|
+
return insertTaskHistory({
|
|
7555
|
+
id: uuid(),
|
|
7556
|
+
task_id: taskId,
|
|
7557
|
+
action,
|
|
7558
|
+
field: field || null,
|
|
7559
|
+
old_value: oldValue ?? null,
|
|
7560
|
+
new_value: newValue ?? null,
|
|
7561
|
+
agent_id: agentId || null,
|
|
7562
|
+
created_at: now(),
|
|
7563
|
+
machine_id: currentStorageMachineId(d)
|
|
7564
|
+
}, d);
|
|
7528
7565
|
}
|
|
7529
7566
|
function getTaskHistory(taskId, db) {
|
|
7530
7567
|
const d = db || getDatabase();
|
|
@@ -8547,6 +8584,145 @@ var init_task_graph = __esm(() => {
|
|
|
8547
8584
|
init_task_crud();
|
|
8548
8585
|
});
|
|
8549
8586
|
|
|
8587
|
+
// src/lib/stale-lock-handoff.ts
|
|
8588
|
+
function normalizeExactTaskId(value) {
|
|
8589
|
+
if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
|
|
8590
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_TASK_ID", "stale-lock handoff requires one exact full task UUID", { task_id: typeof value === "string" ? value : null });
|
|
8591
|
+
}
|
|
8592
|
+
return value.trim().toLowerCase();
|
|
8593
|
+
}
|
|
8594
|
+
function requireNonEmptyString(value, field) {
|
|
8595
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
8596
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
|
|
8597
|
+
}
|
|
8598
|
+
const trimmed = value.trim();
|
|
8599
|
+
if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
|
|
8600
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
|
|
8601
|
+
}
|
|
8602
|
+
return trimmed;
|
|
8603
|
+
}
|
|
8604
|
+
function requireCanonicalLockVersion(value) {
|
|
8605
|
+
if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
|
|
8606
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must be the exact canonical locked_at timestamp (YYYY-MM-DDTHH:mm:ss.sssZ)", { field: "expected_lock_version" });
|
|
8607
|
+
}
|
|
8608
|
+
const parsed = Date.parse(value);
|
|
8609
|
+
if (Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
|
|
8610
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
|
|
8611
|
+
}
|
|
8612
|
+
return value;
|
|
8613
|
+
}
|
|
8614
|
+
function requireStaleThreshold(value) {
|
|
8615
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
|
|
8616
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
|
|
8617
|
+
}
|
|
8618
|
+
return Number(value);
|
|
8619
|
+
}
|
|
8620
|
+
function prepareStaleLockHandoff(input, options = {}) {
|
|
8621
|
+
const taskId = normalizeExactTaskId(input.task_id);
|
|
8622
|
+
const actor = requireNonEmptyString(input.actor, "actor");
|
|
8623
|
+
const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
|
|
8624
|
+
const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
|
|
8625
|
+
const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
|
|
8626
|
+
const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
|
|
8627
|
+
const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
|
|
8628
|
+
if (!reason) {
|
|
8629
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
|
|
8630
|
+
}
|
|
8631
|
+
if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
|
|
8632
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
|
|
8633
|
+
}
|
|
8634
|
+
if (canonicalAgentRef(expectedHolder) === canonicalAgentRef(newHolder)) {
|
|
8635
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "new_holder must differ from expected_holder", { field: "new_holder" });
|
|
8636
|
+
}
|
|
8637
|
+
const operationTimestamp = options.now ?? new Date().toISOString();
|
|
8638
|
+
if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
|
|
8639
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
|
|
8640
|
+
}
|
|
8641
|
+
const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
|
|
8642
|
+
return {
|
|
8643
|
+
task_id: taskId,
|
|
8644
|
+
actor,
|
|
8645
|
+
expected_holder: expectedHolder,
|
|
8646
|
+
expected_lock_version: expectedLockVersion,
|
|
8647
|
+
stale_after_seconds: staleAfterSeconds,
|
|
8648
|
+
new_holder: newHolder,
|
|
8649
|
+
reason,
|
|
8650
|
+
operation_timestamp: operationTimestamp,
|
|
8651
|
+
stale_cutoff: staleCutoff,
|
|
8652
|
+
receipt_id: options.receiptId ?? crypto.randomUUID()
|
|
8653
|
+
};
|
|
8654
|
+
}
|
|
8655
|
+
function buildStaleLockHandoffReceipt(input) {
|
|
8656
|
+
return {
|
|
8657
|
+
schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
|
|
8658
|
+
receipt_id: input.receipt_id,
|
|
8659
|
+
task_id: input.task_id,
|
|
8660
|
+
actor: input.actor,
|
|
8661
|
+
previous_holder: input.expected_holder,
|
|
8662
|
+
previous_lock_version: input.expected_lock_version,
|
|
8663
|
+
new_holder: input.new_holder,
|
|
8664
|
+
new_lock_version: input.operation_timestamp,
|
|
8665
|
+
stale_after_seconds: input.stale_after_seconds,
|
|
8666
|
+
stale_cutoff: input.stale_cutoff,
|
|
8667
|
+
reason: input.reason,
|
|
8668
|
+
created_at: input.operation_timestamp
|
|
8669
|
+
};
|
|
8670
|
+
}
|
|
8671
|
+
function staleLockHandoffHistory(receipt, machineId) {
|
|
8672
|
+
return {
|
|
8673
|
+
id: receipt.receipt_id,
|
|
8674
|
+
task_id: receipt.task_id,
|
|
8675
|
+
action: STALE_LOCK_HANDOFF_ACTION,
|
|
8676
|
+
field: STALE_LOCK_HANDOFF_FIELD,
|
|
8677
|
+
old_value: JSON.stringify({
|
|
8678
|
+
holder: receipt.previous_holder,
|
|
8679
|
+
lock_version: receipt.previous_lock_version
|
|
8680
|
+
}),
|
|
8681
|
+
new_value: JSON.stringify(receipt),
|
|
8682
|
+
agent_id: receipt.actor,
|
|
8683
|
+
created_at: receipt.created_at,
|
|
8684
|
+
machine_id: machineId
|
|
8685
|
+
};
|
|
8686
|
+
}
|
|
8687
|
+
function throwStaleLockHandoffConflict(task, input) {
|
|
8688
|
+
if (!task.locked_by || !task.locked_at) {
|
|
8689
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_LOCKED", `Task ${input.task_id} does not have a complete lock to hand off`, { task_id: input.task_id });
|
|
8690
|
+
}
|
|
8691
|
+
if (task.locked_at !== input.expected_lock_version) {
|
|
8692
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
|
|
8693
|
+
task_id: input.task_id,
|
|
8694
|
+
expected_lock_version: input.expected_lock_version,
|
|
8695
|
+
current_lock_version: task.locked_at
|
|
8696
|
+
});
|
|
8697
|
+
}
|
|
8698
|
+
if (task.locked_by !== input.expected_holder) {
|
|
8699
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
|
|
8700
|
+
task_id: input.task_id,
|
|
8701
|
+
expected_holder: input.expected_holder,
|
|
8702
|
+
current_holder: task.locked_by
|
|
8703
|
+
});
|
|
8704
|
+
}
|
|
8705
|
+
if (isTerminalStatus(task.status)) {
|
|
8706
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_TERMINAL", `Task ${input.task_id} is ${task.status} and cannot transfer a lock`, { task_id: input.task_id, status: task.status });
|
|
8707
|
+
}
|
|
8708
|
+
if (task.locked_at >= input.stale_cutoff) {
|
|
8709
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
|
|
8710
|
+
task_id: input.task_id,
|
|
8711
|
+
current_lock_version: task.locked_at,
|
|
8712
|
+
stale_cutoff: input.stale_cutoff
|
|
8713
|
+
});
|
|
8714
|
+
}
|
|
8715
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
|
|
8716
|
+
}
|
|
8717
|
+
var STALE_LOCK_HANDOFF_SCHEMA_VERSION = "todos.stale-lock-handoff.v1", STALE_LOCK_HANDOFF_ACTION = "stale_lock_handoff", STALE_LOCK_HANDOFF_FIELD = "lock", EXACT_TASK_UUID_RE, CANONICAL_LOCK_VERSION_RE, MAX_REASON_LENGTH = 4096;
|
|
8718
|
+
var init_stale_lock_handoff = __esm(() => {
|
|
8719
|
+
init_types();
|
|
8720
|
+
init_creator_identity();
|
|
8721
|
+
init_prewrite_secrets();
|
|
8722
|
+
EXACT_TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
8723
|
+
CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
8724
|
+
});
|
|
8725
|
+
|
|
8550
8726
|
// src/db/task-lifecycle.ts
|
|
8551
8727
|
var exports_task_lifecycle = {};
|
|
8552
8728
|
__export(exports_task_lifecycle, {
|
|
@@ -8555,6 +8731,7 @@ __export(exports_task_lifecycle, {
|
|
|
8555
8731
|
startTask: () => startTask,
|
|
8556
8732
|
spawnNextRecurrence: () => spawnNextRecurrence,
|
|
8557
8733
|
lockTask: () => lockTask,
|
|
8734
|
+
handoffStaleTaskLock: () => handoffStaleTaskLock,
|
|
8558
8735
|
getTasksChangedSince: () => getTasksChangedSince,
|
|
8559
8736
|
getTaskLockStatus: () => getTaskLockStatus,
|
|
8560
8737
|
getStaleTasks: () => getStaleTasks,
|
|
@@ -8823,6 +9000,38 @@ function unlockTask(id, agentId, db) {
|
|
|
8823
9000
|
WHERE id = ?`, [timestamp2, id]);
|
|
8824
9001
|
return true;
|
|
8825
9002
|
}
|
|
9003
|
+
function handoffStaleTaskLock(input, db) {
|
|
9004
|
+
const d = db || getDatabase();
|
|
9005
|
+
const prepared = prepareStaleLockHandoff(input);
|
|
9006
|
+
const receipt = buildStaleLockHandoffReceipt(prepared);
|
|
9007
|
+
const history = staleLockHandoffHistory(receipt, null);
|
|
9008
|
+
const transfer = d.transaction(() => {
|
|
9009
|
+
const result = d.run(`UPDATE tasks
|
|
9010
|
+
SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
|
|
9011
|
+
WHERE id = ?
|
|
9012
|
+
AND locked_by = ?
|
|
9013
|
+
AND locked_at = ?
|
|
9014
|
+
AND julianday(locked_at) < julianday(?)
|
|
9015
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')`, [
|
|
9016
|
+
prepared.new_holder,
|
|
9017
|
+
prepared.operation_timestamp,
|
|
9018
|
+
prepared.operation_timestamp,
|
|
9019
|
+
prepared.task_id,
|
|
9020
|
+
prepared.expected_holder,
|
|
9021
|
+
prepared.expected_lock_version,
|
|
9022
|
+
prepared.stale_cutoff
|
|
9023
|
+
]);
|
|
9024
|
+
if (result.changes === 0) {
|
|
9025
|
+
const current = getTask(prepared.task_id, d);
|
|
9026
|
+
if (!current)
|
|
9027
|
+
throw new TaskNotFoundError(prepared.task_id);
|
|
9028
|
+
throwStaleLockHandoffConflict(current, prepared);
|
|
9029
|
+
}
|
|
9030
|
+
insertTaskHistory(history, d);
|
|
9031
|
+
});
|
|
9032
|
+
transfer();
|
|
9033
|
+
return receipt;
|
|
9034
|
+
}
|
|
8826
9035
|
function getTaskLockStatus(id, db) {
|
|
8827
9036
|
const d = db || getDatabase();
|
|
8828
9037
|
const task = getTask(id, d);
|
|
@@ -9123,6 +9332,7 @@ var init_task_lifecycle = __esm(() => {
|
|
|
9123
9332
|
init_task_crud();
|
|
9124
9333
|
init_task_graph();
|
|
9125
9334
|
init_prewrite_secrets();
|
|
9335
|
+
init_stale_lock_handoff();
|
|
9126
9336
|
});
|
|
9127
9337
|
|
|
9128
9338
|
// src/db/task-crud.ts
|
|
@@ -13760,6 +13970,36 @@ init_task_lists();
|
|
|
13760
13970
|
init_tasks();
|
|
13761
13971
|
init_templates();
|
|
13762
13972
|
init_storage_tombstones();
|
|
13973
|
+
|
|
13974
|
+
// src/storage/audit-history-import.ts
|
|
13975
|
+
var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
|
|
13976
|
+
var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
|
|
13977
|
+
var AUDIT_HISTORY_FIELDS = [
|
|
13978
|
+
"id",
|
|
13979
|
+
"task_id",
|
|
13980
|
+
"action",
|
|
13981
|
+
"field",
|
|
13982
|
+
"old_value",
|
|
13983
|
+
"new_value",
|
|
13984
|
+
"agent_id",
|
|
13985
|
+
"created_at",
|
|
13986
|
+
"machine_id"
|
|
13987
|
+
];
|
|
13988
|
+
function auditHistoryRowsAreFieldIdentical(left, right) {
|
|
13989
|
+
return AUDIT_HISTORY_FIELDS.every((field) => {
|
|
13990
|
+
const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
|
|
13991
|
+
const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
|
|
13992
|
+
return leftValue === rightValue;
|
|
13993
|
+
});
|
|
13994
|
+
}
|
|
13995
|
+
function divergentAuditHistoryReplayError(id) {
|
|
13996
|
+
return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
|
|
13997
|
+
}
|
|
13998
|
+
function forbiddenAuditHistoryTombstoneError(id) {
|
|
13999
|
+
return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
|
|
14000
|
+
}
|
|
14001
|
+
|
|
14002
|
+
// src/storage/sqlite-snapshot.ts
|
|
13763
14003
|
var PROJECT_COLUMNS = [
|
|
13764
14004
|
"id",
|
|
13765
14005
|
"name",
|
|
@@ -13960,8 +14200,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13960
14200
|
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
13961
14201
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
13962
14202
|
}
|
|
14203
|
+
const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
|
|
14204
|
+
result.errors.push(...auditImport.errors);
|
|
13963
14205
|
if (result.errors.length > 0)
|
|
13964
14206
|
return result;
|
|
14207
|
+
result.skipped += auditImport.identicalReplayCount;
|
|
13965
14208
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
13966
14209
|
for (const row of rows) {
|
|
13967
14210
|
try {
|
|
@@ -13996,10 +14239,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13996
14239
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
13997
14240
|
}
|
|
13998
14241
|
});
|
|
13999
|
-
|
|
14242
|
+
insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
|
|
14000
14243
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
14001
14244
|
return result;
|
|
14002
14245
|
}
|
|
14246
|
+
function preflightAuditHistoryImport(db, rows, tombstones) {
|
|
14247
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
14248
|
+
const rowsToInsert = [];
|
|
14249
|
+
const seen = new Map;
|
|
14250
|
+
let identicalReplayCount = 0;
|
|
14251
|
+
for (const rawRow of rows) {
|
|
14252
|
+
try {
|
|
14253
|
+
const row = asRecord(rawRow);
|
|
14254
|
+
if (typeof row.id !== "string" || !row.id) {
|
|
14255
|
+
throw new Error("task_history row is missing id");
|
|
14256
|
+
}
|
|
14257
|
+
const prior = seen.get(row.id);
|
|
14258
|
+
if (prior) {
|
|
14259
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
14260
|
+
identicalReplayCount += 1;
|
|
14261
|
+
else
|
|
14262
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
14263
|
+
continue;
|
|
14264
|
+
}
|
|
14265
|
+
seen.set(row.id, row);
|
|
14266
|
+
const existing = getAuditHistoryById(db, row.id);
|
|
14267
|
+
if (!existing) {
|
|
14268
|
+
rowsToInsert.push(row);
|
|
14269
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
14270
|
+
identicalReplayCount += 1;
|
|
14271
|
+
} else {
|
|
14272
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
14273
|
+
}
|
|
14274
|
+
} catch (error) {
|
|
14275
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
14276
|
+
}
|
|
14277
|
+
}
|
|
14278
|
+
return { rowsToInsert, identicalReplayCount, errors };
|
|
14279
|
+
}
|
|
14280
|
+
function insertAuditHistoryRows(db, rows, result) {
|
|
14281
|
+
for (const rawRow of rows) {
|
|
14282
|
+
try {
|
|
14283
|
+
const row = asRecord(rawRow);
|
|
14284
|
+
const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
|
|
14285
|
+
if (!presentColumns.includes("id"))
|
|
14286
|
+
presentColumns.unshift("id");
|
|
14287
|
+
const placeholders = presentColumns.map(() => "?").join(", ");
|
|
14288
|
+
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
14289
|
+
const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
|
|
14290
|
+
if (changes > 0) {
|
|
14291
|
+
result.inserted += 1;
|
|
14292
|
+
continue;
|
|
14293
|
+
}
|
|
14294
|
+
const existing = getAuditHistoryById(db, String(row["id"]));
|
|
14295
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
14296
|
+
result.skipped += 1;
|
|
14297
|
+
} else {
|
|
14298
|
+
result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
|
|
14299
|
+
}
|
|
14300
|
+
} catch (error) {
|
|
14301
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
14302
|
+
}
|
|
14303
|
+
}
|
|
14304
|
+
}
|
|
14305
|
+
function getAuditHistoryById(db, id) {
|
|
14306
|
+
return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
|
|
14307
|
+
}
|
|
14003
14308
|
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
14004
14309
|
const id = row["id"];
|
|
14005
14310
|
if (typeof id !== "string" || !id)
|
|
@@ -14097,7 +14402,7 @@ function tableForTombstone(objectType) {
|
|
|
14097
14402
|
return "task_templates";
|
|
14098
14403
|
if (objectType === "template_tasks")
|
|
14099
14404
|
return "template_tasks";
|
|
14100
|
-
|
|
14405
|
+
throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
|
|
14101
14406
|
}
|
|
14102
14407
|
function listRows(db, table, columns) {
|
|
14103
14408
|
return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
|
|
@@ -14229,6 +14534,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14229
14534
|
unlockTask(id, agentId, database());
|
|
14230
14535
|
return true;
|
|
14231
14536
|
},
|
|
14537
|
+
handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
|
|
14232
14538
|
delete: (id) => deleteTask(id, database()),
|
|
14233
14539
|
start: (id, agentId) => startTask(id, agentId, database()),
|
|
14234
14540
|
complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
|
|
@@ -14326,6 +14632,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14326
14632
|
init_types();
|
|
14327
14633
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
14328
14634
|
init_creator_identity();
|
|
14635
|
+
init_stale_lock_handoff();
|
|
14329
14636
|
|
|
14330
14637
|
// src/storage/postgres-sync.ts
|
|
14331
14638
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
@@ -14761,6 +15068,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
14761
15068
|
getChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
14762
15069
|
lock: (id, agentId) => lockTask2(id, agentId, store),
|
|
14763
15070
|
unlock: (id, agentId) => unlockTask2(id, agentId, store),
|
|
15071
|
+
handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
|
|
14764
15072
|
getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
|
|
14765
15073
|
},
|
|
14766
15074
|
dependencies: {
|
|
@@ -14907,6 +15215,91 @@ class PostgresJsonRecordStore {
|
|
|
14907
15215
|
LIMIT 1`, [this.service, type, id]);
|
|
14908
15216
|
return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
|
|
14909
15217
|
}
|
|
15218
|
+
async handoffStaleLock(input, context = {}) {
|
|
15219
|
+
const prepared = prepareStaleLockHandoff(input);
|
|
15220
|
+
const receipt = buildStaleLockHandoffReceipt(prepared);
|
|
15221
|
+
const history = staleLockHandoffHistory(receipt, this.machineId(context));
|
|
15222
|
+
await this.ensureSchema();
|
|
15223
|
+
const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
|
|
15224
|
+
target AS MATERIALIZED (
|
|
15225
|
+
SELECT payload
|
|
15226
|
+
FROM ${this.tableName}
|
|
15227
|
+
WHERE service = $1
|
|
15228
|
+
AND object_type = 'tasks'
|
|
15229
|
+
AND object_id = $2
|
|
15230
|
+
AND deleted_at IS NULL
|
|
15231
|
+
FOR UPDATE
|
|
15232
|
+
),
|
|
15233
|
+
updated AS (
|
|
15234
|
+
UPDATE ${this.tableName} AS task_record
|
|
15235
|
+
SET payload = jsonb_set(
|
|
15236
|
+
jsonb_set(
|
|
15237
|
+
jsonb_set(
|
|
15238
|
+
jsonb_set(
|
|
15239
|
+
task_record.payload,
|
|
15240
|
+
'{locked_by}',
|
|
15241
|
+
to_jsonb($6::text),
|
|
15242
|
+
true
|
|
15243
|
+
),
|
|
15244
|
+
'{locked_at}',
|
|
15245
|
+
to_jsonb($7::text),
|
|
15246
|
+
true
|
|
15247
|
+
),
|
|
15248
|
+
'{updated_at}',
|
|
15249
|
+
to_jsonb($7::text),
|
|
15250
|
+
true
|
|
15251
|
+
),
|
|
15252
|
+
'{version}',
|
|
15253
|
+
to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
|
|
15254
|
+
true
|
|
15255
|
+
),
|
|
15256
|
+
updated_at = $7::timestamptz,
|
|
15257
|
+
source_machine_id = $10,
|
|
15258
|
+
version = COALESCE(task_record.version, 0) + 1
|
|
15259
|
+
FROM target
|
|
15260
|
+
WHERE task_record.service = $1
|
|
15261
|
+
AND task_record.object_type = 'tasks'
|
|
15262
|
+
AND task_record.object_id = $2
|
|
15263
|
+
AND task_record.deleted_at IS NULL
|
|
15264
|
+
AND target.payload->>'locked_by' = $3
|
|
15265
|
+
AND target.payload->>'locked_at' = $4
|
|
15266
|
+
AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
|
|
15267
|
+
AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
|
|
15268
|
+
RETURNING task_record.payload
|
|
15269
|
+
),
|
|
15270
|
+
audit AS (
|
|
15271
|
+
INSERT INTO ${this.tableName} (
|
|
15272
|
+
service, object_type, object_id, payload, updated_at,
|
|
15273
|
+
deleted_at, source_machine_id, version
|
|
15274
|
+
)
|
|
15275
|
+
SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
|
|
15276
|
+
NULL, $10, NULL
|
|
15277
|
+
FROM updated
|
|
15278
|
+
RETURNING payload
|
|
15279
|
+
)
|
|
15280
|
+
SELECT
|
|
15281
|
+
(SELECT payload FROM target) AS current_payload,
|
|
15282
|
+
(SELECT payload FROM updated) AS updated_payload,
|
|
15283
|
+
(SELECT payload FROM audit) AS audit_payload`, [
|
|
15284
|
+
this.service,
|
|
15285
|
+
prepared.task_id,
|
|
15286
|
+
prepared.expected_holder,
|
|
15287
|
+
prepared.expected_lock_version,
|
|
15288
|
+
prepared.stale_cutoff,
|
|
15289
|
+
prepared.new_holder,
|
|
15290
|
+
prepared.operation_timestamp,
|
|
15291
|
+
receipt.receipt_id,
|
|
15292
|
+
jsonbParam(history),
|
|
15293
|
+
this.machineId(context)
|
|
15294
|
+
]);
|
|
15295
|
+
const row = result.rows[0];
|
|
15296
|
+
if (!row?.current_payload)
|
|
15297
|
+
throw new TaskNotFoundError(prepared.task_id);
|
|
15298
|
+
if (!row.updated_payload || !row.audit_payload) {
|
|
15299
|
+
throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
|
|
15300
|
+
}
|
|
15301
|
+
return receipt;
|
|
15302
|
+
}
|
|
14910
15303
|
async list(type) {
|
|
14911
15304
|
return (await this.listRecords(type)).map((record) => record.payload);
|
|
14912
15305
|
}
|
|
@@ -15176,6 +15569,28 @@ class PostgresJsonRecordStore {
|
|
|
15176
15569
|
}
|
|
15177
15570
|
return value;
|
|
15178
15571
|
}
|
|
15572
|
+
async insertImmutableAuditHistory(value, context = {}) {
|
|
15573
|
+
await this.ensureSchema();
|
|
15574
|
+
const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
15575
|
+
service, object_type, object_id, payload, updated_at,
|
|
15576
|
+
deleted_at, source_machine_id, version
|
|
15577
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
|
|
15578
|
+
ON CONFLICT (service, object_type, object_id) DO NOTHING
|
|
15579
|
+
RETURNING object_id`, [
|
|
15580
|
+
this.service,
|
|
15581
|
+
"audit_history",
|
|
15582
|
+
value.id,
|
|
15583
|
+
jsonbParam(value),
|
|
15584
|
+
value.created_at,
|
|
15585
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
15586
|
+
]);
|
|
15587
|
+
if (inserted.rows.length > 0)
|
|
15588
|
+
return "inserted";
|
|
15589
|
+
const existing = await this.get("audit_history", value.id);
|
|
15590
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
|
|
15591
|
+
return "identical";
|
|
15592
|
+
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
15593
|
+
}
|
|
15179
15594
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
15180
15595
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
15181
15596
|
if (planIds.length === 0)
|
|
@@ -16667,6 +17082,11 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16667
17082
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
16668
17083
|
if (result.errors.length > 0)
|
|
16669
17084
|
return result;
|
|
17085
|
+
const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
|
|
17086
|
+
result.errors.push(...auditHistory.errors);
|
|
17087
|
+
if (result.errors.length > 0)
|
|
17088
|
+
return result;
|
|
17089
|
+
result.skipped += auditHistory.identical;
|
|
16670
17090
|
const entries = [
|
|
16671
17091
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
16672
17092
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -16675,9 +17095,20 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16675
17095
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
16676
17096
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
16677
17097
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
16678
|
-
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16679
|
-
...snapshot.auditHistory.map((row) => ["audit_history", row])
|
|
17098
|
+
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16680
17099
|
];
|
|
17100
|
+
for (const row of auditHistory.rowsToInsert) {
|
|
17101
|
+
try {
|
|
17102
|
+
const outcome = await store.insertImmutableAuditHistory(row, context);
|
|
17103
|
+
if (outcome === "inserted")
|
|
17104
|
+
result.inserted += 1;
|
|
17105
|
+
else
|
|
17106
|
+
result.skipped += 1;
|
|
17107
|
+
} catch (error) {
|
|
17108
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
17109
|
+
return result;
|
|
17110
|
+
}
|
|
17111
|
+
}
|
|
16681
17112
|
for (const [type, row] of entries) {
|
|
16682
17113
|
try {
|
|
16683
17114
|
const existing = await store.get(type, row.id);
|
|
@@ -16711,6 +17142,32 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16711
17142
|
}
|
|
16712
17143
|
return result;
|
|
16713
17144
|
}
|
|
17145
|
+
async function preflightAuditHistoryImport2(rows, tombstones, store) {
|
|
17146
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
17147
|
+
const rowsToInsert = [];
|
|
17148
|
+
const seen = new Map;
|
|
17149
|
+
let identical = 0;
|
|
17150
|
+
for (const row of rows) {
|
|
17151
|
+
const prior = seen.get(row.id);
|
|
17152
|
+
if (prior) {
|
|
17153
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
17154
|
+
identical += 1;
|
|
17155
|
+
else
|
|
17156
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
17157
|
+
continue;
|
|
17158
|
+
}
|
|
17159
|
+
seen.set(row.id, row);
|
|
17160
|
+
const existing = await store.get("audit_history", row.id);
|
|
17161
|
+
if (!existing) {
|
|
17162
|
+
rowsToInsert.push(row);
|
|
17163
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
17164
|
+
identical += 1;
|
|
17165
|
+
} else {
|
|
17166
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
17167
|
+
}
|
|
17168
|
+
}
|
|
17169
|
+
return { rowsToInsert, identical, errors };
|
|
17170
|
+
}
|
|
16714
17171
|
async function requireRecord(type, id, store) {
|
|
16715
17172
|
const record = await store.get(type, id);
|
|
16716
17173
|
if (!record)
|
package/dist/task-manifest.js
CHANGED
|
@@ -46,7 +46,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
46
46
|
function isTerminalStatus(status) {
|
|
47
47
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
48
48
|
}
|
|
49
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
49
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
50
50
|
var init_types = __esm(() => {
|
|
51
51
|
TASK_STATUSES = [
|
|
52
52
|
"pending",
|
|
@@ -169,6 +169,16 @@ var init_types = __esm(() => {
|
|
|
169
169
|
this.name = "LockError";
|
|
170
170
|
}
|
|
171
171
|
};
|
|
172
|
+
StaleLockHandoffError = class StaleLockHandoffError extends Error {
|
|
173
|
+
code;
|
|
174
|
+
details;
|
|
175
|
+
constructor(code, message, details = {}) {
|
|
176
|
+
super(message);
|
|
177
|
+
this.code = code;
|
|
178
|
+
this.details = details;
|
|
179
|
+
this.name = "StaleLockHandoffError";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
172
182
|
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
173
183
|
agentId;
|
|
174
184
|
static code = "AGENT_NOT_FOUND";
|