@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/index.js
CHANGED
|
@@ -3662,7 +3662,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
3662
3662
|
function isTerminalStatus(status) {
|
|
3663
3663
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
3664
3664
|
}
|
|
3665
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
3665
|
+
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;
|
|
3666
3666
|
var init_types = __esm(() => {
|
|
3667
3667
|
TASK_STATUSES = [
|
|
3668
3668
|
"pending",
|
|
@@ -3750,6 +3750,19 @@ var init_types = __esm(() => {
|
|
|
3750
3750
|
this.name = "ResourceConflictError";
|
|
3751
3751
|
}
|
|
3752
3752
|
};
|
|
3753
|
+
PlanRevisionConflictError = class PlanRevisionConflictError extends Error {
|
|
3754
|
+
planId;
|
|
3755
|
+
expectedUpdatedAt;
|
|
3756
|
+
currentUpdatedAt;
|
|
3757
|
+
static code = "PLAN_REVISION_CONFLICT";
|
|
3758
|
+
constructor(planId, expectedUpdatedAt, currentUpdatedAt) {
|
|
3759
|
+
super(`Plan revision conflict for ${planId}: expected ${expectedUpdatedAt}, current ${currentUpdatedAt}`);
|
|
3760
|
+
this.planId = planId;
|
|
3761
|
+
this.expectedUpdatedAt = expectedUpdatedAt;
|
|
3762
|
+
this.currentUpdatedAt = currentUpdatedAt;
|
|
3763
|
+
this.name = "PlanRevisionConflictError";
|
|
3764
|
+
}
|
|
3765
|
+
};
|
|
3753
3766
|
PlanNotFoundError = class PlanNotFoundError extends Error {
|
|
3754
3767
|
planId;
|
|
3755
3768
|
static code = "PLAN_NOT_FOUND";
|
|
@@ -3772,6 +3785,16 @@ var init_types = __esm(() => {
|
|
|
3772
3785
|
this.name = "LockError";
|
|
3773
3786
|
}
|
|
3774
3787
|
};
|
|
3788
|
+
StaleLockHandoffError = class StaleLockHandoffError extends Error {
|
|
3789
|
+
code;
|
|
3790
|
+
details;
|
|
3791
|
+
constructor(code, message, details = {}) {
|
|
3792
|
+
super(message);
|
|
3793
|
+
this.code = code;
|
|
3794
|
+
this.details = details;
|
|
3795
|
+
this.name = "StaleLockHandoffError";
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3775
3798
|
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
3776
3799
|
agentId;
|
|
3777
3800
|
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, field2, oldValue, newValue, agentId, db) {
|
|
7553
|
+
const d = db || getDatabase();
|
|
7554
|
+
return insertTaskHistory({
|
|
7555
|
+
id: uuid(),
|
|
7556
|
+
task_id: taskId,
|
|
7557
|
+
action,
|
|
7558
|
+
field: field2 || 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, field2) {
|
|
8595
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
8596
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field2} must be a non-empty string`, { field: field2 });
|
|
8597
|
+
}
|
|
8598
|
+
const trimmed = value.trim();
|
|
8599
|
+
if (field2 === "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: field2, 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);
|
|
@@ -12493,7 +12758,7 @@ var init_dispatches = __esm(() => {
|
|
|
12493
12758
|
// package.json
|
|
12494
12759
|
var package_default = {
|
|
12495
12760
|
name: "@hasna/todos",
|
|
12496
|
-
version: "0.15.
|
|
12761
|
+
version: "0.15.20",
|
|
12497
12762
|
description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
|
|
12498
12763
|
type: "module",
|
|
12499
12764
|
main: "dist/index.js",
|
|
@@ -12564,6 +12829,8 @@ var package_default = {
|
|
|
12564
12829
|
"dev:mcp": "bun run src/mcp/index.ts",
|
|
12565
12830
|
"dev:serve": "bun run src/server/index.ts",
|
|
12566
12831
|
"verify:release": "bun run scripts/verify-public-release.ts --mode=review",
|
|
12832
|
+
"verify:release-review": "bun run scripts/verify-npm-release-agent-review.ts",
|
|
12833
|
+
"issue:release-review": "bun run scripts/issue-npm-release-agent-review.ts",
|
|
12567
12834
|
prepublishOnly: "bun run scripts/verify-public-release.ts --mode=publish",
|
|
12568
12835
|
postinstall: "mkdir -p $HOME/.hasna/todos $HOME/.hasna/todos/training 2>/dev/null || true"
|
|
12569
12836
|
},
|
|
@@ -25564,6 +25831,36 @@ init_task_lists();
|
|
|
25564
25831
|
init_tasks();
|
|
25565
25832
|
init_templates();
|
|
25566
25833
|
init_storage_tombstones();
|
|
25834
|
+
|
|
25835
|
+
// src/storage/audit-history-import.ts
|
|
25836
|
+
var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
|
|
25837
|
+
var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
|
|
25838
|
+
var AUDIT_HISTORY_FIELDS = [
|
|
25839
|
+
"id",
|
|
25840
|
+
"task_id",
|
|
25841
|
+
"action",
|
|
25842
|
+
"field",
|
|
25843
|
+
"old_value",
|
|
25844
|
+
"new_value",
|
|
25845
|
+
"agent_id",
|
|
25846
|
+
"created_at",
|
|
25847
|
+
"machine_id"
|
|
25848
|
+
];
|
|
25849
|
+
function auditHistoryRowsAreFieldIdentical(left, right) {
|
|
25850
|
+
return AUDIT_HISTORY_FIELDS.every((field2) => {
|
|
25851
|
+
const leftValue = field2 === "machine_id" ? left[field2] ?? null : left[field2];
|
|
25852
|
+
const rightValue = field2 === "machine_id" ? right[field2] ?? null : right[field2];
|
|
25853
|
+
return leftValue === rightValue;
|
|
25854
|
+
});
|
|
25855
|
+
}
|
|
25856
|
+
function divergentAuditHistoryReplayError(id) {
|
|
25857
|
+
return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
|
|
25858
|
+
}
|
|
25859
|
+
function forbiddenAuditHistoryTombstoneError(id) {
|
|
25860
|
+
return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
|
|
25861
|
+
}
|
|
25862
|
+
|
|
25863
|
+
// src/storage/sqlite-snapshot.ts
|
|
25567
25864
|
var PROJECT_COLUMNS = [
|
|
25568
25865
|
"id",
|
|
25569
25866
|
"name",
|
|
@@ -25764,9 +26061,12 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
25764
26061
|
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
25765
26062
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
25766
26063
|
}
|
|
26064
|
+
const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
|
|
26065
|
+
result.errors.push(...auditImport.errors);
|
|
25767
26066
|
if (result.errors.length > 0)
|
|
25768
26067
|
return result;
|
|
25769
|
-
|
|
26068
|
+
result.skipped += auditImport.identicalReplayCount;
|
|
26069
|
+
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
25770
26070
|
for (const row of rows) {
|
|
25771
26071
|
try {
|
|
25772
26072
|
const record = asRecord2(row);
|
|
@@ -25775,7 +26075,7 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
25775
26075
|
result.skipped += 1;
|
|
25776
26076
|
continue;
|
|
25777
26077
|
}
|
|
25778
|
-
const state = upsertById(d, table, columns, record, updateClockColumn);
|
|
26078
|
+
const state = upsertById(d, table, columns, record, updateClockColumn, acceptEqualClock);
|
|
25779
26079
|
if (state === "inserted")
|
|
25780
26080
|
result.inserted += 1;
|
|
25781
26081
|
else if (state === "updated")
|
|
@@ -25792,19 +26092,81 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
25792
26092
|
applyRows("project_machine_paths", "project_machine_paths", PROJECT_MACHINE_PATH_COLUMNS, snapshot.projectMachinePaths ?? [], "updated_at");
|
|
25793
26093
|
applyRows("agents", "agents", AGENT_COLUMNS, snapshot.agents, "last_seen_at");
|
|
25794
26094
|
applyRows("task_lists", "task_lists", TASK_LIST_COLUMNS, snapshot.taskLists, "updated_at");
|
|
25795
|
-
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at");
|
|
26095
|
+
applyRows("plans", "plans", PLAN_COLUMNS, snapshot.plans, "updated_at", false);
|
|
25796
26096
|
applyRows("templates", "task_templates", TEMPLATE_COLUMNS, snapshot.templates);
|
|
25797
26097
|
applyRows("template_tasks", "template_tasks", TEMPLATE_TASK_COLUMNS, snapshot.templateTasks ?? []);
|
|
25798
|
-
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", (row, changed) => {
|
|
26098
|
+
applyRows("tasks", "tasks", TASK_COLUMNS, sortedTasks2(snapshot.tasks), "updated_at", true, (row, changed) => {
|
|
25799
26099
|
if (changed && Array.isArray(row["tags"]) && typeof row["id"] === "string") {
|
|
25800
26100
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
25801
26101
|
}
|
|
25802
26102
|
});
|
|
25803
|
-
|
|
26103
|
+
insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
|
|
25804
26104
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
25805
26105
|
return result;
|
|
25806
26106
|
}
|
|
25807
|
-
function
|
|
26107
|
+
function preflightAuditHistoryImport(db, rows, tombstones) {
|
|
26108
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
26109
|
+
const rowsToInsert = [];
|
|
26110
|
+
const seen = new Map;
|
|
26111
|
+
let identicalReplayCount = 0;
|
|
26112
|
+
for (const rawRow of rows) {
|
|
26113
|
+
try {
|
|
26114
|
+
const row = asRecord2(rawRow);
|
|
26115
|
+
if (typeof row.id !== "string" || !row.id) {
|
|
26116
|
+
throw new Error("task_history row is missing id");
|
|
26117
|
+
}
|
|
26118
|
+
const prior = seen.get(row.id);
|
|
26119
|
+
if (prior) {
|
|
26120
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
26121
|
+
identicalReplayCount += 1;
|
|
26122
|
+
else
|
|
26123
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
26124
|
+
continue;
|
|
26125
|
+
}
|
|
26126
|
+
seen.set(row.id, row);
|
|
26127
|
+
const existing = getAuditHistoryById(db, row.id);
|
|
26128
|
+
if (!existing) {
|
|
26129
|
+
rowsToInsert.push(row);
|
|
26130
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
26131
|
+
identicalReplayCount += 1;
|
|
26132
|
+
} else {
|
|
26133
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
26134
|
+
}
|
|
26135
|
+
} catch (error) {
|
|
26136
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
26137
|
+
}
|
|
26138
|
+
}
|
|
26139
|
+
return { rowsToInsert, identicalReplayCount, errors };
|
|
26140
|
+
}
|
|
26141
|
+
function insertAuditHistoryRows(db, rows, result) {
|
|
26142
|
+
for (const rawRow of rows) {
|
|
26143
|
+
try {
|
|
26144
|
+
const row = asRecord2(rawRow);
|
|
26145
|
+
const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
|
|
26146
|
+
if (!presentColumns.includes("id"))
|
|
26147
|
+
presentColumns.unshift("id");
|
|
26148
|
+
const placeholders = presentColumns.map(() => "?").join(", ");
|
|
26149
|
+
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
26150
|
+
const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
|
|
26151
|
+
if (changes > 0) {
|
|
26152
|
+
result.inserted += 1;
|
|
26153
|
+
continue;
|
|
26154
|
+
}
|
|
26155
|
+
const existing = getAuditHistoryById(db, String(row["id"]));
|
|
26156
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
26157
|
+
result.skipped += 1;
|
|
26158
|
+
} else {
|
|
26159
|
+
result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
|
|
26160
|
+
}
|
|
26161
|
+
} catch (error) {
|
|
26162
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
26163
|
+
}
|
|
26164
|
+
}
|
|
26165
|
+
}
|
|
26166
|
+
function getAuditHistoryById(db, id) {
|
|
26167
|
+
return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
|
|
26168
|
+
}
|
|
26169
|
+
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
25808
26170
|
const id = row["id"];
|
|
25809
26171
|
if (typeof id !== "string" || !id)
|
|
25810
26172
|
throw new Error(`${table} row is missing id`);
|
|
@@ -25816,7 +26178,7 @@ function upsertById(db, table, columns, row, updateClockColumn) {
|
|
|
25816
26178
|
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
25817
26179
|
const updateColumns = presentColumns.filter((column) => column !== "id");
|
|
25818
26180
|
const updateSet = updateColumns.map((column) => column === "version" ? `version = MAX(COALESCE(${table}.version, 0), excluded.version)` : `${column} = excluded.${column}`).join(", ");
|
|
25819
|
-
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} <= excluded.${updateClockColumn}` : "";
|
|
26181
|
+
const clockGuard = updateClockColumn && presentColumns.includes(updateClockColumn) ? ` WHERE ${table}.${updateClockColumn} IS NULL OR ${table}.${updateClockColumn} ${acceptEqualClock ? "<=" : "<"} excluded.${updateClockColumn}` : "";
|
|
25820
26182
|
const sql = updateSet ? `INSERT INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})
|
|
25821
26183
|
ON CONFLICT(id) DO UPDATE SET ${updateSet}${clockGuard}` : `INSERT OR IGNORE INTO ${table} (${presentColumns.join(", ")}) VALUES (${placeholders})`;
|
|
25822
26184
|
const changes = db.run(sql, values).changes;
|
|
@@ -25901,7 +26263,7 @@ function tableForTombstone(objectType) {
|
|
|
25901
26263
|
return "task_templates";
|
|
25902
26264
|
if (objectType === "template_tasks")
|
|
25903
26265
|
return "template_tasks";
|
|
25904
|
-
|
|
26266
|
+
throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
|
|
25905
26267
|
}
|
|
25906
26268
|
function listRows(db, table, columns) {
|
|
25907
26269
|
return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
|
|
@@ -26033,6 +26395,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
26033
26395
|
unlockTask(id, agentId, database());
|
|
26034
26396
|
return true;
|
|
26035
26397
|
},
|
|
26398
|
+
handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
|
|
26036
26399
|
delete: (id) => deleteTask(id, database()),
|
|
26037
26400
|
start: (id, agentId) => startTask(id, agentId, database()),
|
|
26038
26401
|
complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
|
|
@@ -26056,6 +26419,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
26056
26419
|
get: (id) => getPlan(id, database()),
|
|
26057
26420
|
list: (projectId) => listPlans(projectId, database()),
|
|
26058
26421
|
update: (id, input) => updatePlan(id, input, database()),
|
|
26422
|
+
completeAtRevision: (id, expectedUpdatedAt) => completePlanAtRevision(id, expectedUpdatedAt, database()),
|
|
26059
26423
|
delete: (id) => deletePlan(id, database())
|
|
26060
26424
|
},
|
|
26061
26425
|
planProjectLinks: {
|
|
@@ -26129,6 +26493,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
26129
26493
|
init_types();
|
|
26130
26494
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
26131
26495
|
init_creator_identity();
|
|
26496
|
+
init_stale_lock_handoff();
|
|
26132
26497
|
|
|
26133
26498
|
// src/storage/postgres-sync.ts
|
|
26134
26499
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
@@ -26564,6 +26929,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
26564
26929
|
getChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
26565
26930
|
lock: (id, agentId) => lockTask2(id, agentId, store),
|
|
26566
26931
|
unlock: (id, agentId) => unlockTask2(id, agentId, store),
|
|
26932
|
+
handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
|
|
26567
26933
|
getByFingerprint: (fingerprint2) => store.getTaskByFingerprint(fingerprint2)
|
|
26568
26934
|
},
|
|
26569
26935
|
dependencies: {
|
|
@@ -26600,6 +26966,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
26600
26966
|
get: (id) => store.get("plans", id),
|
|
26601
26967
|
list: async (projectId) => (await store.list("plans")).filter((plan) => projectId === undefined || plan.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
26602
26968
|
update: (id, input) => updatePlan2(id, input, store),
|
|
26969
|
+
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
26603
26970
|
delete: (id, context) => store.deletePlan(id, context)
|
|
26604
26971
|
},
|
|
26605
26972
|
planProjectLinks: {
|
|
@@ -26709,6 +27076,91 @@ class PostgresJsonRecordStore {
|
|
|
26709
27076
|
LIMIT 1`, [this.service, type, id]);
|
|
26710
27077
|
return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
|
|
26711
27078
|
}
|
|
27079
|
+
async handoffStaleLock(input, context = {}) {
|
|
27080
|
+
const prepared = prepareStaleLockHandoff(input);
|
|
27081
|
+
const receipt = buildStaleLockHandoffReceipt(prepared);
|
|
27082
|
+
const history = staleLockHandoffHistory(receipt, this.machineId(context));
|
|
27083
|
+
await this.ensureSchema();
|
|
27084
|
+
const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
|
|
27085
|
+
target AS MATERIALIZED (
|
|
27086
|
+
SELECT payload
|
|
27087
|
+
FROM ${this.tableName}
|
|
27088
|
+
WHERE service = $1
|
|
27089
|
+
AND object_type = 'tasks'
|
|
27090
|
+
AND object_id = $2
|
|
27091
|
+
AND deleted_at IS NULL
|
|
27092
|
+
FOR UPDATE
|
|
27093
|
+
),
|
|
27094
|
+
updated AS (
|
|
27095
|
+
UPDATE ${this.tableName} AS task_record
|
|
27096
|
+
SET payload = jsonb_set(
|
|
27097
|
+
jsonb_set(
|
|
27098
|
+
jsonb_set(
|
|
27099
|
+
jsonb_set(
|
|
27100
|
+
task_record.payload,
|
|
27101
|
+
'{locked_by}',
|
|
27102
|
+
to_jsonb($6::text),
|
|
27103
|
+
true
|
|
27104
|
+
),
|
|
27105
|
+
'{locked_at}',
|
|
27106
|
+
to_jsonb($7::text),
|
|
27107
|
+
true
|
|
27108
|
+
),
|
|
27109
|
+
'{updated_at}',
|
|
27110
|
+
to_jsonb($7::text),
|
|
27111
|
+
true
|
|
27112
|
+
),
|
|
27113
|
+
'{version}',
|
|
27114
|
+
to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
|
|
27115
|
+
true
|
|
27116
|
+
),
|
|
27117
|
+
updated_at = $7::timestamptz,
|
|
27118
|
+
source_machine_id = $10,
|
|
27119
|
+
version = COALESCE(task_record.version, 0) + 1
|
|
27120
|
+
FROM target
|
|
27121
|
+
WHERE task_record.service = $1
|
|
27122
|
+
AND task_record.object_type = 'tasks'
|
|
27123
|
+
AND task_record.object_id = $2
|
|
27124
|
+
AND task_record.deleted_at IS NULL
|
|
27125
|
+
AND target.payload->>'locked_by' = $3
|
|
27126
|
+
AND target.payload->>'locked_at' = $4
|
|
27127
|
+
AND todos_try_timestamptz(target.payload->>'locked_at') < $5::timestamptz
|
|
27128
|
+
AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
|
|
27129
|
+
RETURNING task_record.payload
|
|
27130
|
+
),
|
|
27131
|
+
audit AS (
|
|
27132
|
+
INSERT INTO ${this.tableName} (
|
|
27133
|
+
service, object_type, object_id, payload, updated_at,
|
|
27134
|
+
deleted_at, source_machine_id, version
|
|
27135
|
+
)
|
|
27136
|
+
SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
|
|
27137
|
+
NULL, $10, NULL
|
|
27138
|
+
FROM updated
|
|
27139
|
+
RETURNING payload
|
|
27140
|
+
)
|
|
27141
|
+
SELECT
|
|
27142
|
+
(SELECT payload FROM target) AS current_payload,
|
|
27143
|
+
(SELECT payload FROM updated) AS updated_payload,
|
|
27144
|
+
(SELECT payload FROM audit) AS audit_payload`, [
|
|
27145
|
+
this.service,
|
|
27146
|
+
prepared.task_id,
|
|
27147
|
+
prepared.expected_holder,
|
|
27148
|
+
prepared.expected_lock_version,
|
|
27149
|
+
prepared.stale_cutoff,
|
|
27150
|
+
prepared.new_holder,
|
|
27151
|
+
prepared.operation_timestamp,
|
|
27152
|
+
receipt.receipt_id,
|
|
27153
|
+
jsonbParam(history),
|
|
27154
|
+
this.machineId(context)
|
|
27155
|
+
]);
|
|
27156
|
+
const row = result.rows[0];
|
|
27157
|
+
if (!row?.current_payload)
|
|
27158
|
+
throw new TaskNotFoundError(prepared.task_id);
|
|
27159
|
+
if (!row.updated_payload || !row.audit_payload) {
|
|
27160
|
+
throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
|
|
27161
|
+
}
|
|
27162
|
+
return receipt;
|
|
27163
|
+
}
|
|
26712
27164
|
async list(type) {
|
|
26713
27165
|
return (await this.listRecords(type)).map((record) => record.payload);
|
|
26714
27166
|
}
|
|
@@ -26978,6 +27430,28 @@ class PostgresJsonRecordStore {
|
|
|
26978
27430
|
}
|
|
26979
27431
|
return value;
|
|
26980
27432
|
}
|
|
27433
|
+
async insertImmutableAuditHistory(value, context = {}) {
|
|
27434
|
+
await this.ensureSchema();
|
|
27435
|
+
const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
27436
|
+
service, object_type, object_id, payload, updated_at,
|
|
27437
|
+
deleted_at, source_machine_id, version
|
|
27438
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
|
|
27439
|
+
ON CONFLICT (service, object_type, object_id) DO NOTHING
|
|
27440
|
+
RETURNING object_id`, [
|
|
27441
|
+
this.service,
|
|
27442
|
+
"audit_history",
|
|
27443
|
+
value.id,
|
|
27444
|
+
jsonbParam(value),
|
|
27445
|
+
value.created_at,
|
|
27446
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
27447
|
+
]);
|
|
27448
|
+
if (inserted.rows.length > 0)
|
|
27449
|
+
return "inserted";
|
|
27450
|
+
const existing = await this.get("audit_history", value.id);
|
|
27451
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
|
|
27452
|
+
return "identical";
|
|
27453
|
+
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
27454
|
+
}
|
|
26981
27455
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
26982
27456
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
26983
27457
|
if (planIds.length === 0)
|
|
@@ -27087,6 +27561,54 @@ class PostgresJsonRecordStore {
|
|
|
27087
27561
|
throw new PlanNotFoundError(value.id);
|
|
27088
27562
|
return payloadRecord2(row.payload);
|
|
27089
27563
|
}
|
|
27564
|
+
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
27565
|
+
await this.ensureSchema();
|
|
27566
|
+
const result = await this.options.client.query(`/* todos:complete-plan-revision-cas */ WITH next_clock AS (
|
|
27567
|
+
SELECT date_trunc(
|
|
27568
|
+
'milliseconds',
|
|
27569
|
+
GREATEST(clock_timestamp(), ($3::text)::timestamptz + interval '2 milliseconds')
|
|
27570
|
+
) AS completed_at
|
|
27571
|
+
), stored AS (
|
|
27572
|
+
UPDATE ${this.tableName} AS record SET
|
|
27573
|
+
payload = record.payload || jsonb_build_object(
|
|
27574
|
+
'status', 'completed',
|
|
27575
|
+
'updated_at', to_char(
|
|
27576
|
+
next_clock.completed_at AT TIME ZONE 'UTC',
|
|
27577
|
+
'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
|
|
27578
|
+
)
|
|
27579
|
+
),
|
|
27580
|
+
updated_at = next_clock.completed_at,
|
|
27581
|
+
deleted_at = NULL,
|
|
27582
|
+
source_machine_id = COALESCE($4, record.source_machine_id),
|
|
27583
|
+
version = COALESCE(record.version, 0) + 1
|
|
27584
|
+
FROM next_clock
|
|
27585
|
+
WHERE record.service = $1
|
|
27586
|
+
AND record.object_type = 'plans'
|
|
27587
|
+
AND record.object_id = $2
|
|
27588
|
+
AND record.deleted_at IS NULL
|
|
27589
|
+
AND record.payload->>'updated_at' = $3::text
|
|
27590
|
+
AND record.payload->>'status' IS DISTINCT FROM 'completed'
|
|
27591
|
+
RETURNING record.payload
|
|
27592
|
+
)
|
|
27593
|
+
SELECT payload FROM stored`, [
|
|
27594
|
+
this.service,
|
|
27595
|
+
id,
|
|
27596
|
+
expectedUpdatedAt,
|
|
27597
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
27598
|
+
]);
|
|
27599
|
+
const payload = result.rows[0]?.payload;
|
|
27600
|
+
if (payload)
|
|
27601
|
+
return { plan: payloadRecord2(payload), applied: true };
|
|
27602
|
+
const current = await this.get("plans", id);
|
|
27603
|
+
if (!current)
|
|
27604
|
+
throw new PlanNotFoundError(id);
|
|
27605
|
+
if (current.updated_at !== expectedUpdatedAt) {
|
|
27606
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
27607
|
+
}
|
|
27608
|
+
if (current.status === "completed")
|
|
27609
|
+
return { plan: current, applied: false };
|
|
27610
|
+
throw new PlanRevisionConflictError(id, expectedUpdatedAt, current.updated_at);
|
|
27611
|
+
}
|
|
27090
27612
|
async createTemplateWithTasks(template, tasks, context = {}) {
|
|
27091
27613
|
await this.ensureSchema();
|
|
27092
27614
|
const records = [
|
|
@@ -28421,6 +28943,11 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
28421
28943
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
28422
28944
|
if (result.errors.length > 0)
|
|
28423
28945
|
return result;
|
|
28946
|
+
const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
|
|
28947
|
+
result.errors.push(...auditHistory.errors);
|
|
28948
|
+
if (result.errors.length > 0)
|
|
28949
|
+
return result;
|
|
28950
|
+
result.skipped += auditHistory.identical;
|
|
28424
28951
|
const entries = [
|
|
28425
28952
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
28426
28953
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -28429,9 +28956,20 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
28429
28956
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
28430
28957
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
28431
28958
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
28432
|
-
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
28433
|
-
...snapshot.auditHistory.map((row) => ["audit_history", row])
|
|
28959
|
+
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
28434
28960
|
];
|
|
28961
|
+
for (const row of auditHistory.rowsToInsert) {
|
|
28962
|
+
try {
|
|
28963
|
+
const outcome = await store.insertImmutableAuditHistory(row, context);
|
|
28964
|
+
if (outcome === "inserted")
|
|
28965
|
+
result.inserted += 1;
|
|
28966
|
+
else
|
|
28967
|
+
result.skipped += 1;
|
|
28968
|
+
} catch (error) {
|
|
28969
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
28970
|
+
return result;
|
|
28971
|
+
}
|
|
28972
|
+
}
|
|
28435
28973
|
for (const [type, row] of entries) {
|
|
28436
28974
|
try {
|
|
28437
28975
|
const existing = await store.get(type, row.id);
|
|
@@ -28465,6 +29003,32 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
28465
29003
|
}
|
|
28466
29004
|
return result;
|
|
28467
29005
|
}
|
|
29006
|
+
async function preflightAuditHistoryImport2(rows, tombstones, store) {
|
|
29007
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
29008
|
+
const rowsToInsert = [];
|
|
29009
|
+
const seen = new Map;
|
|
29010
|
+
let identical = 0;
|
|
29011
|
+
for (const row of rows) {
|
|
29012
|
+
const prior = seen.get(row.id);
|
|
29013
|
+
if (prior) {
|
|
29014
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
29015
|
+
identical += 1;
|
|
29016
|
+
else
|
|
29017
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
29018
|
+
continue;
|
|
29019
|
+
}
|
|
29020
|
+
seen.set(row.id, row);
|
|
29021
|
+
const existing = await store.get("audit_history", row.id);
|
|
29022
|
+
if (!existing) {
|
|
29023
|
+
rowsToInsert.push(row);
|
|
29024
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
29025
|
+
identical += 1;
|
|
29026
|
+
} else {
|
|
29027
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
29028
|
+
}
|
|
29029
|
+
}
|
|
29030
|
+
return { rowsToInsert, identical, errors };
|
|
29031
|
+
}
|
|
28468
29032
|
async function requireRecord(type, id, store) {
|
|
28469
29033
|
const record = await store.get(type, id);
|
|
28470
29034
|
if (!record)
|
|
@@ -28865,6 +29429,15 @@ function createShadowTodosStorageAdapter(options) {
|
|
|
28865
29429
|
mirror.enqueueUpsert("plans", plan, context);
|
|
28866
29430
|
return plan;
|
|
28867
29431
|
},
|
|
29432
|
+
async completeAtRevision(id, expectedUpdatedAt, context) {
|
|
29433
|
+
if (typeof local.plans.completeAtRevision !== "function") {
|
|
29434
|
+
throw new Error("Atomic plan completion is not supported by the local shadow adapter");
|
|
29435
|
+
}
|
|
29436
|
+
const completed = await local.plans.completeAtRevision(id, expectedUpdatedAt, context);
|
|
29437
|
+
if (completed.applied)
|
|
29438
|
+
mirror.enqueueUpsert("plans", completed.plan, context);
|
|
29439
|
+
return completed;
|
|
29440
|
+
},
|
|
28868
29441
|
async delete(id, context) {
|
|
28869
29442
|
const deleted = await local.plans.delete(id, context);
|
|
28870
29443
|
if (deleted)
|