@hasna/todos 0.15.18 → 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/project-commands.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 +1306 -122
- package/dist/cli/stage-a.d.ts +21 -10
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/contracts.js +283 -16
- package/dist/db/audit.d.ts +8 -0
- package/dist/db/audit.d.ts.map +1 -1
- package/dist/db/plans.d.ts +4 -0
- package/dist/db/plans.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 +599 -26
- 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 +954 -46
- package/dist/mcp.js +3 -1
- package/dist/project-registration.js +4250 -3684
- package/dist/registry.js +283 -16
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.js +7 -0
- package/dist/sdk/v1.generated.d.ts +40 -1
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1287 -379
- package/dist/server/openapi.d.ts +232 -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 +22 -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/shadow.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.js +596 -25
- package/dist/task-manifest.js +24 -1
- package/dist/types/index.d.ts +50 -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, 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",
|
|
@@ -364,6 +364,19 @@ var init_types = __esm(() => {
|
|
|
364
364
|
this.name = "ResourceConflictError";
|
|
365
365
|
}
|
|
366
366
|
};
|
|
367
|
+
PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
|
|
368
|
+
planId;
|
|
369
|
+
expectedUpdatedAt;
|
|
370
|
+
currentUpdatedAt;
|
|
371
|
+
static code = "PLAN_REVISION_CONFLICT";
|
|
372
|
+
constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
|
|
373
|
+
super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
|
|
374
|
+
this.planId = planId;
|
|
375
|
+
this.expectedUpdatedAt = expectedUpdatedAt;
|
|
376
|
+
this.currentUpdatedAt = currentUpdatedAt;
|
|
377
|
+
this.name = "PlanRevisionConflictError";
|
|
378
|
+
}
|
|
379
|
+
};
|
|
367
380
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
368
381
|
planId;
|
|
369
382
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -386,6 +399,16 @@ var init_types = __esm(() => {
|
|
|
386
399
|
this.name = "LockError";
|
|
387
400
|
}
|
|
388
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
|
+
};
|
|
389
412
|
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
390
413
|
agentId;
|
|
391
414
|
static code = "AGENT_NOT_FOUND";
|
|
@@ -7490,28 +7513,55 @@ var init_activity_audit = __esm(() => {
|
|
|
7490
7513
|
function sanitizeHistoryValue(value, context) {
|
|
7491
7514
|
return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
|
|
7492
7515
|
}
|
|
7493
|
-
function
|
|
7516
|
+
function insertTaskHistory(entry, db) {
|
|
7494
7517
|
const d = db || getDatabase();
|
|
7495
|
-
const
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
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
|
+
};
|
|
7500
7526
|
d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
|
|
7501
|
-
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
|
+
]);
|
|
7502
7538
|
try {
|
|
7503
7539
|
const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
|
|
7504
7540
|
logActivity2({
|
|
7505
7541
|
entity_type: "task",
|
|
7506
|
-
entity_id:
|
|
7507
|
-
action,
|
|
7508
|
-
field,
|
|
7509
|
-
old_value:
|
|
7510
|
-
new_value:
|
|
7511
|
-
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
|
|
7512
7548
|
}, d);
|
|
7513
7549
|
} catch {}
|
|
7514
|
-
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);
|
|
7515
7565
|
}
|
|
7516
7566
|
function getTaskHistory(taskId, db) {
|
|
7517
7567
|
const d = db || getDatabase();
|
|
@@ -8534,6 +8584,145 @@ var init_task_graph = __esm(() => {
|
|
|
8534
8584
|
init_task_crud();
|
|
8535
8585
|
});
|
|
8536
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
|
+
|
|
8537
8726
|
// src/db/task-lifecycle.ts
|
|
8538
8727
|
var exports_task_lifecycle = {};
|
|
8539
8728
|
__export(exports_task_lifecycle, {
|
|
@@ -8542,6 +8731,7 @@ __export(exports_task_lifecycle, {
|
|
|
8542
8731
|
startTask: () => startTask,
|
|
8543
8732
|
spawnNextRecurrence: () => spawnNextRecurrence,
|
|
8544
8733
|
lockTask: () => lockTask,
|
|
8734
|
+
handoffStaleTaskLock: () => handoffStaleTaskLock,
|
|
8545
8735
|
getTasksChangedSince: () => getTasksChangedSince,
|
|
8546
8736
|
getTaskLockStatus: () => getTaskLockStatus,
|
|
8547
8737
|
getStaleTasks: () => getStaleTasks,
|
|
@@ -8810,6 +9000,38 @@ function unlockTask(id, agentId, db) {
|
|
|
8810
9000
|
WHERE id = ?`, [timestamp2, id]);
|
|
8811
9001
|
return true;
|
|
8812
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
|
+
}
|
|
8813
9035
|
function getTaskLockStatus(id, db) {
|
|
8814
9036
|
const d = db || getDatabase();
|
|
8815
9037
|
const task = getTask(id, d);
|
|
@@ -9110,6 +9332,7 @@ var init_task_lifecycle = __esm(() => {
|
|
|
9110
9332
|
init_task_crud();
|
|
9111
9333
|
init_task_graph();
|
|
9112
9334
|
init_prewrite_secrets();
|
|
9335
|
+
init_stale_lock_handoff();
|
|
9113
9336
|
});
|
|
9114
9337
|
|
|
9115
9338
|
// src/db/task-crud.ts
|
|
@@ -10521,6 +10744,48 @@ function updatePlan(id, input, db) {
|
|
|
10521
10744
|
return updatePlanStored(id, input, d);
|
|
10522
10745
|
})();
|
|
10523
10746
|
}
|
|
10747
|
+
function nextPlanCompletionTimestamp(expectedUpdatedAt) {
|
|
10748
|
+
const expected = Date.parse(expectedUpdatedAt);
|
|
10749
|
+
const minimum = Number.isNaN(expected) ? Date.now() : expected + 2;
|
|
10750
|
+
return new Date(Math.max(Date.now(), minimum)).toISOString();
|
|
10751
|
+
}
|
|
10752
|
+
function completePlanAtRevision(id, expectedUpdatedAt, db) {
|
|
10753
|
+
const d = db || getDatabase();
|
|
10754
|
+
return d.transaction(() => {
|
|
10755
|
+
guardPlanRowsSqlite([id], d);
|
|
10756
|
+
const plan = getPlan(id, d);
|
|
10757
|
+
if (!plan)
|
|
10758
|
+
throw new PlanNotFoundError(id);
|
|
10759
|
+
if (plan.updated_at !== expectedUpdatedAt) {
|
|
10760
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, plan.updated_at);
|
|
10761
|
+
}
|
|
10762
|
+
if (plan.status === "completed")
|
|
10763
|
+
return { plan, applied: false };
|
|
10764
|
+
const updatedAt = nextPlanCompletionTimestamp(expectedUpdatedAt);
|
|
10765
|
+
const result = d.run(`UPDATE plans
|
|
10766
|
+
SET status = 'completed', updated_at = ?
|
|
10767
|
+
WHERE id = ? AND updated_at = ? AND status <> 'completed'`, [updatedAt, id, expectedUpdatedAt]);
|
|
10768
|
+
if (result.changes !== 1) {
|
|
10769
|
+
const current = getPlan(id, d);
|
|
10770
|
+
if (!current)
|
|
10771
|
+
throw new PlanNotFoundError(id);
|
|
10772
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
10773
|
+
}
|
|
10774
|
+
const completed = getPlan(id, d);
|
|
10775
|
+
emitLocalEventHooksQuiet({
|
|
10776
|
+
type: "plan.updated",
|
|
10777
|
+
payload: {
|
|
10778
|
+
id,
|
|
10779
|
+
old_status: plan.status,
|
|
10780
|
+
new_status: completed.status,
|
|
10781
|
+
name: completed.name,
|
|
10782
|
+
project_id: completed.project_id
|
|
10783
|
+
},
|
|
10784
|
+
databasePath: databasePathFromDatabase(d)
|
|
10785
|
+
});
|
|
10786
|
+
return { plan: completed, applied: true };
|
|
10787
|
+
})();
|
|
10788
|
+
}
|
|
10524
10789
|
function deletePlan(id, db) {
|
|
10525
10790
|
const d = db || getDatabase();
|
|
10526
10791
|
const plan = getPlan(id, d);
|
|
@@ -13705,6 +13970,36 @@ init_task_lists();
|
|
|
13705
13970
|
init_tasks();
|
|
13706
13971
|
init_templates();
|
|
13707
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
|
|
13708
14003
|
var PROJECT_COLUMNS = [
|
|
13709
14004
|
"id",
|
|
13710
14005
|
"name",
|
|
@@ -13905,9 +14200,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13905
14200
|
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
13906
14201
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
13907
14202
|
}
|
|
14203
|
+
const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
|
|
14204
|
+
result.errors.push(...auditImport.errors);
|
|
13908
14205
|
if (result.errors.length > 0)
|
|
13909
14206
|
return result;
|
|
13910
|
-
|
|
14207
|
+
result.skipped += auditImport.identicalReplayCount;
|
|
14208
|
+
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
13911
14209
|
for (const row of rows) {
|
|
13912
14210
|
try {
|
|
13913
14211
|
const record = asRecord(row);
|
|
@@ -13916,7 +14214,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13916
14214
|
result.skipped += 1;
|
|
13917
14215
|
continue;
|
|
13918
14216
|
}
|
|
13919
|
-
const state = upsertById(d, table, columns, record, updateClockColumn);
|
|
14217
|
+
const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
|
|
13920
14218
|
if (state === "inserted")
|
|
13921
14219
|
result.inserted += 1;
|
|
13922
14220
|
else if (state === "updated")
|
|
@@ -13933,19 +14231,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13933
14231
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
13934
14232
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
13935
14233
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
13936
|
-
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
|
|
14234
|
+
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
|
|
13937
14235
|
applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
|
|
13938
14236
|
applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
|
|
13939
|
-
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", (row, changed) => {
|
|
14237
|
+
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks(snapshot.tasks), "updated_at", true, (row, changed) => {
|
|
13940
14238
|
if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
|
|
13941
14239
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
13942
14240
|
}
|
|
13943
14241
|
});
|
|
13944
|
-
|
|
14242
|
+
insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
|
|
13945
14243
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
13946
14244
|
return result;
|
|
13947
14245
|
}
|
|
13948
|
-
function
|
|
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
|
+
}
|
|
14308
|
+
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
13949
14309
|
const id = row["id"];
|
|
13950
14310
|
if (typeof id !== "string" || !id)
|
|
13951
14311
|
throw new Error(`${table} row is missing id`);
|
|
@@ -13957,7 +14317,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
|
|
|
13957
14317
|
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
13958
14318
|
const updateColumns = presentColumns.filter((column) => column !== "id");
|
|
13959
14319
|
const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
|
|
13960
|
-
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
|
|
14320
|
+
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
|
|
13961
14321
|
const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
|
|
13962
14322
|
ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
|
|
13963
14323
|
const changes = db.run(sql, values).changes;
|
|
@@ -14042,7 +14402,7 @@ function tableForTombstone(objectType) {
|
|
|
14042
14402
|
return "task_templates";
|
|
14043
14403
|
if (objectType === "template_tasks")
|
|
14044
14404
|
return "template_tasks";
|
|
14045
|
-
|
|
14405
|
+
throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
|
|
14046
14406
|
}
|
|
14047
14407
|
function listRows(db, table, columns) {
|
|
14048
14408
|
return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
|
|
@@ -14174,6 +14534,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14174
14534
|
unlockTask(id, agentId, database());
|
|
14175
14535
|
return true;
|
|
14176
14536
|
},
|
|
14537
|
+
handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
|
|
14177
14538
|
delete: (id) => deleteTask(id, database()),
|
|
14178
14539
|
start: (id, agentId) => startTask(id, agentId, database()),
|
|
14179
14540
|
complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
|
|
@@ -14197,6 +14558,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14197
14558
|
get: (id) => getPlan(id, database()),
|
|
14198
14559
|
list: (projectId) => listPlans(projectId, database()),
|
|
14199
14560
|
update: (id, input) => updatePlan(id, input, database()),
|
|
14561
|
+
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
14200
14562
|
delete: (id) => deletePlan(id, database())
|
|
14201
14563
|
},
|
|
14202
14564
|
planProjectLinks: {
|
|
@@ -14270,6 +14632,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14270
14632
|
init_types();
|
|
14271
14633
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
14272
14634
|
init_creator_identity();
|
|
14635
|
+
init_stale_lock_handoff();
|
|
14273
14636
|
|
|
14274
14637
|
// src/storage/postgres-sync.ts
|
|
14275
14638
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
@@ -14705,6 +15068,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
14705
15068
|
getChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
14706
15069
|
lock: (id, agentId) => lockTask2(id, agentId, store),
|
|
14707
15070
|
unlock: (id, agentId) => unlockTask2(id, agentId, store),
|
|
15071
|
+
handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
|
|
14708
15072
|
getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
|
|
14709
15073
|
},
|
|
14710
15074
|
dependencies: {
|
|
@@ -14741,6 +15105,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
14741
15105
|
get: (id) => store.get("plans", id),
|
|
14742
15106
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
14743
15107
|
update: (id, input) => updatePlan2(id, input, store),
|
|
15108
|
+
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
14744
15109
|
delete: (id, context) => store.deletePlan(id, context)
|
|
14745
15110
|
},
|
|
14746
15111
|
planProjectLinks: {
|
|
@@ -14850,6 +15215,91 @@ class PostgresJsonRecordStore {
|
|
|
14850
15215
|
LIMIT 1`, [this.service, type, id]);
|
|
14851
15216
|
return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
|
|
14852
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
|
+
}
|
|
14853
15303
|
async list(type) {
|
|
14854
15304
|
return (await this.listRecords(type)).map((record) => record.payload);
|
|
14855
15305
|
}
|
|
@@ -15119,6 +15569,28 @@ class PostgresJsonRecordStore {
|
|
|
15119
15569
|
}
|
|
15120
15570
|
return value;
|
|
15121
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
|
+
}
|
|
15122
15594
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
15123
15595
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
15124
15596
|
if (planIds.length === 0)
|
|
@@ -15228,6 +15700,54 @@ class PostgresJsonRecordStore {
|
|
|
15228
15700
|
throw new PlanNotFoundError(value.id);
|
|
15229
15701
|
return payloadRecord2(row.payload);
|
|
15230
15702
|
}
|
|
15703
|
+
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
15704
|
+
await this.ensureSchema();
|
|
15705
|
+
const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
|
|
15706
|
+
SELECT date_trunc(
|
|
15707
|
+
'milliseconds',
|
|
15708
|
+
GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
|
|
15709
|
+
) AS completed_at
|
|
15710
|
+
), stored AS (
|
|
15711
|
+
UPDATE ${this.tableName} AS record SET
|
|
15712
|
+
payload = record.payload || jsonb_build_object(
|
|
15713
|
+
'status', 'completed',
|
|
15714
|
+
'updated_at', to_char(
|
|
15715
|
+
next_clock.completed_at AT TIME ZONE 'UTC',
|
|
15716
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
15717
|
+
)
|
|
15718
|
+
),
|
|
15719
|
+
updated_at = next_clock.completed_at,
|
|
15720
|
+
deleted_at = NULL,
|
|
15721
|
+
source_machine_id = COALESCE($4, record.source_machine_id),
|
|
15722
|
+
version = COALESCE(record.version, 0) + 1
|
|
15723
|
+
FROM next_clock
|
|
15724
|
+
WHERE record.service = $1
|
|
15725
|
+
AND record.object_type = 'plans'
|
|
15726
|
+
AND record.object_id = $2
|
|
15727
|
+
AND record.deleted_at IS NULL
|
|
15728
|
+
AND record.payload->>'updated_at' = $3::text
|
|
15729
|
+
AND record.payload->>'status' IS DISTINCT FROM 'completed'
|
|
15730
|
+
RETURNING record.payload
|
|
15731
|
+
)
|
|
15732
|
+
SELECT payload FROM stored`, [
|
|
15733
|
+
this.service,
|
|
15734
|
+
id,
|
|
15735
|
+
expectedUpdatedAt,
|
|
15736
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
15737
|
+
]);
|
|
15738
|
+
const payload = result.rows[0]?.payload;
|
|
15739
|
+
if (payload)
|
|
15740
|
+
return { plan: payloadRecord2(payload), applied: true };
|
|
15741
|
+
const current = await this.get("plans", id);
|
|
15742
|
+
if (!current)
|
|
15743
|
+
throw new PlanNotFoundError(id);
|
|
15744
|
+
if (current.updated_at !== expectedUpdatedAt) {
|
|
15745
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
15746
|
+
}
|
|
15747
|
+
if (current.status === "completed")
|
|
15748
|
+
return { plan: current, applied: false };
|
|
15749
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
15750
|
+
}
|
|
15231
15751
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
15232
15752
|
await this.ensureSchema();
|
|
15233
15753
|
const records = [
|
|
@@ -16562,6 +17082,11 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16562
17082
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
16563
17083
|
if (result.errors.length > 0)
|
|
16564
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;
|
|
16565
17090
|
const entries = [
|
|
16566
17091
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
16567
17092
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -16570,9 +17095,20 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16570
17095
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
16571
17096
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
16572
17097
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
16573
|
-
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16574
|
-
...snapshot.auditHistory.map((row) => ["audit_history", row])
|
|
17098
|
+
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16575
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
|
+
}
|
|
16576
17112
|
for (const [type, row] of entries) {
|
|
16577
17113
|
try {
|
|
16578
17114
|
const existing = await store.get(type, row.id);
|
|
@@ -16606,6 +17142,32 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16606
17142
|
}
|
|
16607
17143
|
return result;
|
|
16608
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
|
+
}
|
|
16609
17171
|
async function requireRecord(type, id, store) {
|
|
16610
17172
|
const record = await store.get(type, id);
|
|
16611
17173
|
if (!record)
|
|
@@ -17006,6 +17568,15 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
17006
17568
|
mirror.enqueueUpsert("plans", plan, context);
|
|
17007
17569
|
return plan;
|
|
17008
17570
|
},
|
|
17571
|
+
async completeAtRevision(id, expectedUpdatedAt, context) {
|
|
17572
|
+
if (typeof local.plans.completeAtRevision !== "function") {
|
|
17573
|
+
throw new Error("Atomic plan completion is not supported by the local shadow adapter");
|
|
17574
|
+
}
|
|
17575
|
+
const completed = await local.plans.completeAtRevision(id, expectedUpdatedAt, context);
|
|
17576
|
+
if (completed.applied)
|
|
17577
|
+
mirror.enqueueUpsert("plans", completed.plan, context);
|
|
17578
|
+
return completed;
|
|
17579
|
+
},
|
|
17009
17580
|
async delete(id, context) {
|
|
17010
17581
|
const deleted = await local.plans.delete(id, context);
|
|
17011
17582
|
if (deleted)
|