@hasna/todos 0.15.19 → 0.15.24
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/ai-tools.d.ts +80 -0
- package/dist/ai-tools.d.ts.map +1 -0
- package/dist/ai.d.ts +313 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/cli/cloud-router.d.ts +30 -2
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/ai-commands.d.ts +3 -0
- package/dist/cli/commands/ai-commands.d.ts.map +1 -0
- package/dist/cli/commands/help-commands.d.ts +2 -1
- package/dist/cli/commands/help-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 +3685 -572
- package/dist/cli/stage-a.d.ts +34 -12
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/contracts.d.ts +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +861 -29
- package/dist/db/audit.d.ts +8 -0
- package/dist/db/audit.d.ts.map +1 -1
- package/dist/db/task-lifecycle.d.ts +7 -1
- package/dist/db/task-lifecycle.d.ts.map +1 -1
- package/dist/db/tasks.d.ts +1 -1
- package/dist/db/tasks.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3717 -1205
- package/dist/lib/cli-help.d.ts +3 -2
- package/dist/lib/cli-help.d.ts.map +1 -1
- package/dist/lib/config.d.ts +4 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/saved-search-views.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 +822 -101
- package/dist/mcp.js +3 -1
- package/dist/project-registration.js +4208 -3709
- package/dist/registry.js +826 -29
- package/dist/release-provenance.json +5 -5
- package/dist/sdk/index.js +8 -1
- package/dist/sdk/v1.generated.d.ts +29 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/index.js +1155 -434
- package/dist/server/openapi.d.ts +189 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/audit-history-import.d.ts +14 -0
- package/dist/storage/audit-history-import.d.ts.map +1 -0
- package/dist/storage/interfaces.d.ts +12 -1
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.js +531 -34
- package/dist/task-manifest.js +30 -4
- package/dist/types/index.d.ts +43 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +3 -1
package/dist/storage.js
CHANGED
|
@@ -276,7 +276,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
276
276
|
function isTerminalStatus(status) {
|
|
277
277
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
278
278
|
}
|
|
279
|
-
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
279
|
+
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, TaskNotStartableError, TaskReferenceAmbiguousError, ProjectNotFoundError, ResourceConflictError, PlanRevisionConflictError, PlanNotFoundError, LockError, StaleLockHandoffError, AgentNotFoundError, IdentityAliasAmbiguousError, IdentityIdImmutableError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
280
280
|
var init_types = __esm(() => {
|
|
281
281
|
TASK_STATUSES = [
|
|
282
282
|
"pending",
|
|
@@ -399,6 +399,16 @@ var init_types = __esm(() => {
|
|
|
399
399
|
this.name = "LockError";
|
|
400
400
|
}
|
|
401
401
|
};
|
|
402
|
+
StaleLockHandoffError = class StaleLockHandoffError extends Error {
|
|
403
|
+
code;
|
|
404
|
+
details;
|
|
405
|
+
constructor(code, message, details = {}) {
|
|
406
|
+
super(message);
|
|
407
|
+
this.code = code;
|
|
408
|
+
this.details = details;
|
|
409
|
+
this.name = "StaleLockHandoffError";
|
|
410
|
+
}
|
|
411
|
+
};
|
|
402
412
|
AgentNotFoundError = class AgentNotFoundError extends Error {
|
|
403
413
|
agentId;
|
|
404
414
|
static code = "AGENT_NOT_FOUND";
|
|
@@ -4721,7 +4731,7 @@ var init_database = __esm(() => {
|
|
|
4721
4731
|
});
|
|
4722
4732
|
|
|
4723
4733
|
// src/lib/config.ts
|
|
4724
|
-
import { existsSync as existsSync4 } from "fs";
|
|
4734
|
+
import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
|
|
4725
4735
|
import { dirname as dirname2, join as join3 } from "path";
|
|
4726
4736
|
function getConfigPath() {
|
|
4727
4737
|
return join3(getTodosGlobalDir(), "config.json");
|
|
@@ -4753,6 +4763,22 @@ function saveConfig(config) {
|
|
|
4753
4763
|
function updateConfig(patch) {
|
|
4754
4764
|
return saveConfig({ ...loadConfig(), ...patch });
|
|
4755
4765
|
}
|
|
4766
|
+
function getTodosAiConfig() {
|
|
4767
|
+
const configPath = getConfigPath();
|
|
4768
|
+
if (!existsSync4(configPath))
|
|
4769
|
+
return {};
|
|
4770
|
+
const parsed = JSON.parse(readFileSync2(configPath, "utf8"));
|
|
4771
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4772
|
+
throw new Error("Todos config must be a JSON object");
|
|
4773
|
+
}
|
|
4774
|
+
const ai = parsed["ai"];
|
|
4775
|
+
if (ai === undefined)
|
|
4776
|
+
return {};
|
|
4777
|
+
if (ai === null || typeof ai !== "object" || Array.isArray(ai)) {
|
|
4778
|
+
throw new Error("Todos AI configuration must be an object");
|
|
4779
|
+
}
|
|
4780
|
+
return { ...ai };
|
|
4781
|
+
}
|
|
4756
4782
|
function normalizeApiUrl(value) {
|
|
4757
4783
|
const trimmed = value?.trim();
|
|
4758
4784
|
if (!trimmed)
|
|
@@ -7083,7 +7109,7 @@ var init_shared_events = __esm(() => {
|
|
|
7083
7109
|
});
|
|
7084
7110
|
|
|
7085
7111
|
// src/lib/secret-redaction.ts
|
|
7086
|
-
import { readFileSync as
|
|
7112
|
+
import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
|
|
7087
7113
|
function registerCustomRedactor(fn) {
|
|
7088
7114
|
customRedactors.push(fn);
|
|
7089
7115
|
}
|
|
@@ -7150,7 +7176,7 @@ function scanAndRedactText(text, options = {}) {
|
|
|
7150
7176
|
function scanFileForSecrets(path, options = {}) {
|
|
7151
7177
|
if (!existsSync6(path))
|
|
7152
7178
|
throw new Error(`File not found: ${path}`);
|
|
7153
|
-
const content =
|
|
7179
|
+
const content = readFileSync3(path, "utf8");
|
|
7154
7180
|
return scanAndRedactText(content, options);
|
|
7155
7181
|
}
|
|
7156
7182
|
function safeStringify(value, space) {
|
|
@@ -7503,28 +7529,55 @@ var init_activity_audit = __esm(() => {
|
|
|
7503
7529
|
function sanitizeHistoryValue(value, context) {
|
|
7504
7530
|
return value === undefined || value === null ? null : sanitizePreWriteText(String(value), context);
|
|
7505
7531
|
}
|
|
7506
|
-
function
|
|
7532
|
+
function insertTaskHistory(entry, db) {
|
|
7507
7533
|
const d = db || getDatabase();
|
|
7508
|
-
const
|
|
7509
|
-
|
|
7510
|
-
|
|
7511
|
-
|
|
7512
|
-
|
|
7534
|
+
const safeEntry = {
|
|
7535
|
+
...entry,
|
|
7536
|
+
field: entry.field || null,
|
|
7537
|
+
old_value: sanitizeHistoryValue(entry.old_value, "task_history.old_value"),
|
|
7538
|
+
new_value: sanitizeHistoryValue(entry.new_value, "task_history.new_value"),
|
|
7539
|
+
agent_id: entry.agent_id || null,
|
|
7540
|
+
machine_id: entry.machine_id ?? currentStorageMachineId(d)
|
|
7541
|
+
};
|
|
7513
7542
|
d.run(`INSERT INTO task_history (id, task_id, action, field, old_value, new_value, agent_id, created_at, machine_id)
|
|
7514
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
7543
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
7544
|
+
safeEntry.id,
|
|
7545
|
+
safeEntry.task_id,
|
|
7546
|
+
safeEntry.action,
|
|
7547
|
+
safeEntry.field,
|
|
7548
|
+
safeEntry.old_value,
|
|
7549
|
+
safeEntry.new_value,
|
|
7550
|
+
safeEntry.agent_id,
|
|
7551
|
+
safeEntry.created_at,
|
|
7552
|
+
safeEntry.machine_id ?? null
|
|
7553
|
+
]);
|
|
7515
7554
|
try {
|
|
7516
7555
|
const { logActivity: logActivity2 } = (init_activity_audit(), __toCommonJS(exports_activity_audit));
|
|
7517
7556
|
logActivity2({
|
|
7518
7557
|
entity_type: "task",
|
|
7519
|
-
entity_id:
|
|
7520
|
-
action,
|
|
7521
|
-
field,
|
|
7522
|
-
old_value:
|
|
7523
|
-
new_value:
|
|
7524
|
-
actor_id:
|
|
7558
|
+
entity_id: safeEntry.task_id,
|
|
7559
|
+
action: safeEntry.action,
|
|
7560
|
+
field: safeEntry.field ?? undefined,
|
|
7561
|
+
old_value: safeEntry.old_value,
|
|
7562
|
+
new_value: safeEntry.new_value,
|
|
7563
|
+
actor_id: safeEntry.agent_id ?? undefined
|
|
7525
7564
|
}, d);
|
|
7526
7565
|
} catch {}
|
|
7527
|
-
return
|
|
7566
|
+
return safeEntry;
|
|
7567
|
+
}
|
|
7568
|
+
function logTaskChange(taskId, action, field, oldValue, newValue, agentId, db) {
|
|
7569
|
+
const d = db || getDatabase();
|
|
7570
|
+
return insertTaskHistory({
|
|
7571
|
+
id: uuid(),
|
|
7572
|
+
task_id: taskId,
|
|
7573
|
+
action,
|
|
7574
|
+
field: field || null,
|
|
7575
|
+
old_value: oldValue ?? null,
|
|
7576
|
+
new_value: newValue ?? null,
|
|
7577
|
+
agent_id: agentId || null,
|
|
7578
|
+
created_at: now(),
|
|
7579
|
+
machine_id: currentStorageMachineId(d)
|
|
7580
|
+
}, d);
|
|
7528
7581
|
}
|
|
7529
7582
|
function getTaskHistory(taskId, db) {
|
|
7530
7583
|
const d = db || getDatabase();
|
|
@@ -8547,6 +8600,142 @@ var init_task_graph = __esm(() => {
|
|
|
8547
8600
|
init_task_crud();
|
|
8548
8601
|
});
|
|
8549
8602
|
|
|
8603
|
+
// src/lib/stale-lock-handoff.ts
|
|
8604
|
+
function normalizeExactTaskId(value) {
|
|
8605
|
+
if (typeof value !== "string" || !EXACT_TASK_UUID_RE.test(value.trim())) {
|
|
8606
|
+
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 });
|
|
8607
|
+
}
|
|
8608
|
+
return value.trim().toLowerCase();
|
|
8609
|
+
}
|
|
8610
|
+
function requireNonEmptyString(value, field) {
|
|
8611
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
8612
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `${field} must be a non-empty string`, { field });
|
|
8613
|
+
}
|
|
8614
|
+
const trimmed = value.trim();
|
|
8615
|
+
if (field === "reason" && trimmed.length > MAX_REASON_LENGTH) {
|
|
8616
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", `reason must be at most ${MAX_REASON_LENGTH} characters`, { field, max_length: MAX_REASON_LENGTH });
|
|
8617
|
+
}
|
|
8618
|
+
return trimmed;
|
|
8619
|
+
}
|
|
8620
|
+
function requireCanonicalLockVersion(value) {
|
|
8621
|
+
if (typeof value !== "string" || !CANONICAL_LOCK_VERSION_RE.test(value)) {
|
|
8622
|
+
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" });
|
|
8623
|
+
}
|
|
8624
|
+
const parsed = Date.parse(value);
|
|
8625
|
+
if (value.startsWith("0000-") || Number.isNaN(parsed) || new Date(parsed).toISOString() !== value) {
|
|
8626
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "expected_lock_version must name a real canonical UTC instant", { field: "expected_lock_version" });
|
|
8627
|
+
}
|
|
8628
|
+
return value;
|
|
8629
|
+
}
|
|
8630
|
+
function requireStaleThreshold(value) {
|
|
8631
|
+
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
|
|
8632
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "stale_after_seconds must be a positive safe integer", { field: "stale_after_seconds" });
|
|
8633
|
+
}
|
|
8634
|
+
return Number(value);
|
|
8635
|
+
}
|
|
8636
|
+
function prepareStaleLockHandoff(input, options = {}) {
|
|
8637
|
+
const taskId = normalizeExactTaskId(input.task_id);
|
|
8638
|
+
const actor = requireNonEmptyString(input.actor, "actor");
|
|
8639
|
+
const expectedHolder = requireNonEmptyString(input.expected_holder, "expected_holder");
|
|
8640
|
+
const newHolder = requireNonEmptyString(input.new_holder, "new_holder");
|
|
8641
|
+
const expectedLockVersion = requireCanonicalLockVersion(input.expected_lock_version);
|
|
8642
|
+
const staleAfterSeconds = requireStaleThreshold(input.stale_after_seconds);
|
|
8643
|
+
const reason = sanitizePreWriteText(requireNonEmptyString(input.reason, "reason"), "stale_lock_handoff.reason").trim();
|
|
8644
|
+
if (!reason) {
|
|
8645
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "reason must remain non-empty after safety filtering", { field: "reason" });
|
|
8646
|
+
}
|
|
8647
|
+
if (canonicalAgentRef(actor) !== canonicalAgentRef(newHolder)) {
|
|
8648
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_ACTOR_MISMATCH", "new_holder must match the authenticated actor", { actor, new_holder: newHolder });
|
|
8649
|
+
}
|
|
8650
|
+
const operationTimestamp = options.now ?? new Date().toISOString();
|
|
8651
|
+
if (!CANONICAL_LOCK_VERSION_RE.test(operationTimestamp) || new Date(Date.parse(operationTimestamp)).toISOString() !== operationTimestamp) {
|
|
8652
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_INVALID_INPUT", "operation timestamp must be a canonical UTC instant");
|
|
8653
|
+
}
|
|
8654
|
+
const staleCutoff = new Date(Date.parse(operationTimestamp) - staleAfterSeconds * 1000).toISOString();
|
|
8655
|
+
return {
|
|
8656
|
+
task_id: taskId,
|
|
8657
|
+
actor,
|
|
8658
|
+
expected_holder: expectedHolder,
|
|
8659
|
+
expected_lock_version: expectedLockVersion,
|
|
8660
|
+
stale_after_seconds: staleAfterSeconds,
|
|
8661
|
+
new_holder: newHolder,
|
|
8662
|
+
reason,
|
|
8663
|
+
operation_timestamp: operationTimestamp,
|
|
8664
|
+
stale_cutoff: staleCutoff,
|
|
8665
|
+
receipt_id: options.receiptId ?? crypto.randomUUID()
|
|
8666
|
+
};
|
|
8667
|
+
}
|
|
8668
|
+
function buildStaleLockHandoffReceipt(input) {
|
|
8669
|
+
return {
|
|
8670
|
+
schema_version: STALE_LOCK_HANDOFF_SCHEMA_VERSION,
|
|
8671
|
+
receipt_id: input.receipt_id,
|
|
8672
|
+
task_id: input.task_id,
|
|
8673
|
+
actor: input.actor,
|
|
8674
|
+
previous_holder: input.expected_holder,
|
|
8675
|
+
previous_lock_version: input.expected_lock_version,
|
|
8676
|
+
new_holder: input.new_holder,
|
|
8677
|
+
new_lock_version: input.operation_timestamp,
|
|
8678
|
+
stale_after_seconds: input.stale_after_seconds,
|
|
8679
|
+
stale_cutoff: input.stale_cutoff,
|
|
8680
|
+
reason: input.reason,
|
|
8681
|
+
created_at: input.operation_timestamp
|
|
8682
|
+
};
|
|
8683
|
+
}
|
|
8684
|
+
function staleLockHandoffHistory(receipt, machineId) {
|
|
8685
|
+
return {
|
|
8686
|
+
id: receipt.receipt_id,
|
|
8687
|
+
task_id: receipt.task_id,
|
|
8688
|
+
action: STALE_LOCK_HANDOFF_ACTION,
|
|
8689
|
+
field: STALE_LOCK_HANDOFF_FIELD,
|
|
8690
|
+
old_value: JSON.stringify({
|
|
8691
|
+
holder: receipt.previous_holder,
|
|
8692
|
+
lock_version: receipt.previous_lock_version
|
|
8693
|
+
}),
|
|
8694
|
+
new_value: JSON.stringify(receipt),
|
|
8695
|
+
agent_id: receipt.actor,
|
|
8696
|
+
created_at: receipt.created_at,
|
|
8697
|
+
machine_id: machineId
|
|
8698
|
+
};
|
|
8699
|
+
}
|
|
8700
|
+
function throwStaleLockHandoffConflict(task, input) {
|
|
8701
|
+
if (!task.locked_by || !task.locked_at) {
|
|
8702
|
+
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 });
|
|
8703
|
+
}
|
|
8704
|
+
if (task.locked_at !== input.expected_lock_version) {
|
|
8705
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_VERSION_MISMATCH", `Task ${input.task_id} lock version changed`, {
|
|
8706
|
+
task_id: input.task_id,
|
|
8707
|
+
expected_lock_version: input.expected_lock_version,
|
|
8708
|
+
current_lock_version: task.locked_at
|
|
8709
|
+
});
|
|
8710
|
+
}
|
|
8711
|
+
if (task.locked_by !== input.expected_holder) {
|
|
8712
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_HOLDER_MISMATCH", `Task ${input.task_id} lock holder changed`, {
|
|
8713
|
+
task_id: input.task_id,
|
|
8714
|
+
expected_holder: input.expected_holder,
|
|
8715
|
+
current_holder: task.locked_by
|
|
8716
|
+
});
|
|
8717
|
+
}
|
|
8718
|
+
if (isTerminalStatus(task.status)) {
|
|
8719
|
+
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 });
|
|
8720
|
+
}
|
|
8721
|
+
if (task.locked_at >= input.stale_cutoff) {
|
|
8722
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_NOT_STALE", `Task ${input.task_id} lock is not older than the supplied stale threshold`, {
|
|
8723
|
+
task_id: input.task_id,
|
|
8724
|
+
current_lock_version: task.locked_at,
|
|
8725
|
+
stale_cutoff: input.stale_cutoff
|
|
8726
|
+
});
|
|
8727
|
+
}
|
|
8728
|
+
throw new StaleLockHandoffError("STALE_LOCK_HANDOFF_CONFLICT", `Task ${input.task_id} changed during stale-lock handoff`, { task_id: input.task_id });
|
|
8729
|
+
}
|
|
8730
|
+
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;
|
|
8731
|
+
var init_stale_lock_handoff = __esm(() => {
|
|
8732
|
+
init_types();
|
|
8733
|
+
init_creator_identity();
|
|
8734
|
+
init_prewrite_secrets();
|
|
8735
|
+
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;
|
|
8736
|
+
CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
8737
|
+
});
|
|
8738
|
+
|
|
8550
8739
|
// src/db/task-lifecycle.ts
|
|
8551
8740
|
var exports_task_lifecycle = {};
|
|
8552
8741
|
__export(exports_task_lifecycle, {
|
|
@@ -8555,6 +8744,7 @@ __export(exports_task_lifecycle, {
|
|
|
8555
8744
|
startTask: () => startTask,
|
|
8556
8745
|
spawnNextRecurrence: () => spawnNextRecurrence,
|
|
8557
8746
|
lockTask: () => lockTask,
|
|
8747
|
+
handoffStaleTaskLock: () => handoffStaleTaskLock,
|
|
8558
8748
|
getTasksChangedSince: () => getTasksChangedSince,
|
|
8559
8749
|
getTaskLockStatus: () => getTaskLockStatus,
|
|
8560
8750
|
getStaleTasks: () => getStaleTasks,
|
|
@@ -8823,6 +9013,38 @@ function unlockTask(id, agentId, db) {
|
|
|
8823
9013
|
WHERE id = ?`, [timestamp2, id]);
|
|
8824
9014
|
return true;
|
|
8825
9015
|
}
|
|
9016
|
+
function handoffStaleTaskLock(input, db) {
|
|
9017
|
+
const d = db || getDatabase();
|
|
9018
|
+
const prepared = prepareStaleLockHandoff(input);
|
|
9019
|
+
const receipt = buildStaleLockHandoffReceipt(prepared);
|
|
9020
|
+
const history = staleLockHandoffHistory(receipt, null);
|
|
9021
|
+
const transfer = d.transaction(() => {
|
|
9022
|
+
const result = d.run(`UPDATE tasks
|
|
9023
|
+
SET locked_by = ?, locked_at = ?, updated_at = ?, version = version + 1
|
|
9024
|
+
WHERE id = ?
|
|
9025
|
+
AND locked_by = ?
|
|
9026
|
+
AND locked_at = ?
|
|
9027
|
+
AND julianday(locked_at) < julianday(?)
|
|
9028
|
+
AND status NOT IN ('completed', 'failed', 'cancelled')`, [
|
|
9029
|
+
prepared.new_holder,
|
|
9030
|
+
prepared.operation_timestamp,
|
|
9031
|
+
prepared.operation_timestamp,
|
|
9032
|
+
prepared.task_id,
|
|
9033
|
+
prepared.expected_holder,
|
|
9034
|
+
prepared.expected_lock_version,
|
|
9035
|
+
prepared.stale_cutoff
|
|
9036
|
+
]);
|
|
9037
|
+
if (result.changes === 0) {
|
|
9038
|
+
const current = getTask(prepared.task_id, d);
|
|
9039
|
+
if (!current)
|
|
9040
|
+
throw new TaskNotFoundError(prepared.task_id);
|
|
9041
|
+
throwStaleLockHandoffConflict(current, prepared);
|
|
9042
|
+
}
|
|
9043
|
+
insertTaskHistory(history, d);
|
|
9044
|
+
});
|
|
9045
|
+
transfer();
|
|
9046
|
+
return receipt;
|
|
9047
|
+
}
|
|
8826
9048
|
function getTaskLockStatus(id, db) {
|
|
8827
9049
|
const d = db || getDatabase();
|
|
8828
9050
|
const task = getTask(id, d);
|
|
@@ -9123,6 +9345,7 @@ var init_task_lifecycle = __esm(() => {
|
|
|
9123
9345
|
init_task_crud();
|
|
9124
9346
|
init_task_graph();
|
|
9125
9347
|
init_prewrite_secrets();
|
|
9348
|
+
init_stale_lock_handoff();
|
|
9126
9349
|
});
|
|
9127
9350
|
|
|
9128
9351
|
// src/db/task-crud.ts
|
|
@@ -11001,7 +11224,7 @@ var init_boards = __esm(() => {
|
|
|
11001
11224
|
|
|
11002
11225
|
// src/lib/artifact-store.ts
|
|
11003
11226
|
import { createHash as createHash3 } from "crypto";
|
|
11004
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as
|
|
11227
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11005
11228
|
import { basename, dirname as dirname4, join as join5, resolve as resolve7 } from "path";
|
|
11006
11229
|
import { tmpdir as tmpdir2 } from "os";
|
|
11007
11230
|
function isInMemoryDb2(path) {
|
|
@@ -11068,7 +11291,7 @@ function storeArtifactContent(input) {
|
|
|
11068
11291
|
const sourceStat = statSync2(sourcePath);
|
|
11069
11292
|
if (!sourceStat.isFile())
|
|
11070
11293
|
throw new Error(`Artifact path is not a file: ${sanitizePreWriteText(input.path, "artifact.path")}`);
|
|
11071
|
-
const sourceBuffer =
|
|
11294
|
+
const sourceBuffer = readFileSync4(sourcePath);
|
|
11072
11295
|
const sourceSha = sha256(sourceBuffer);
|
|
11073
11296
|
const textLike = isTextLike(sourceBuffer, input.path);
|
|
11074
11297
|
let storedBuffer = sourceBuffer;
|
|
@@ -11155,7 +11378,7 @@ function verifyStoredArtifact(input) {
|
|
|
11155
11378
|
message: "stored artifact content is missing"
|
|
11156
11379
|
};
|
|
11157
11380
|
}
|
|
11158
|
-
const buffer =
|
|
11381
|
+
const buffer = readFileSync4(storedPath);
|
|
11159
11382
|
const actualSha = sha256(buffer);
|
|
11160
11383
|
const actualSize = buffer.length;
|
|
11161
11384
|
const ok = actualSha === store.sha256 && actualSize === store.size_bytes;
|
|
@@ -11175,7 +11398,7 @@ function exportStoredArtifactContent(input) {
|
|
|
11175
11398
|
const report = verifyStoredArtifact(input);
|
|
11176
11399
|
if (report.status !== "ok" || !report.relative_path || !report.actual_sha256 || report.actual_size_bytes === null)
|
|
11177
11400
|
return null;
|
|
11178
|
-
const content =
|
|
11401
|
+
const content = readFileSync4(artifactStorePath(report.relative_path));
|
|
11179
11402
|
return {
|
|
11180
11403
|
artifact_id: input.id,
|
|
11181
11404
|
sha256: report.actual_sha256,
|
|
@@ -11226,7 +11449,7 @@ function getArtifactStoreRoot(dbPath) {
|
|
|
11226
11449
|
return join5(dirname4(resolve7(path)), "artifacts");
|
|
11227
11450
|
}
|
|
11228
11451
|
function computeContentHash(path) {
|
|
11229
|
-
return sha256(
|
|
11452
|
+
return sha256(readFileSync4(resolve7(path)));
|
|
11230
11453
|
}
|
|
11231
11454
|
function storeArtifactFile(input) {
|
|
11232
11455
|
const sourcePath = resolve7(input.sourcePath);
|
|
@@ -11236,7 +11459,7 @@ function storeArtifactFile(input) {
|
|
|
11236
11459
|
if (!statSync2(sourcePath).isFile()) {
|
|
11237
11460
|
throw new Error(`Source path is not a file: ${input.sourcePath}`);
|
|
11238
11461
|
}
|
|
11239
|
-
const buffer =
|
|
11462
|
+
const buffer = readFileSync4(sourcePath);
|
|
11240
11463
|
const contentHash = sha256(buffer);
|
|
11241
11464
|
const mimeType = mediaTypeFor(sourcePath, isTextLike(buffer, sourcePath));
|
|
11242
11465
|
const storageMode = input.storageMode ?? "copy";
|
|
@@ -13760,6 +13983,36 @@ init_task_lists();
|
|
|
13760
13983
|
init_tasks();
|
|
13761
13984
|
init_templates();
|
|
13762
13985
|
init_storage_tombstones();
|
|
13986
|
+
|
|
13987
|
+
// src/storage/audit-history-import.ts
|
|
13988
|
+
var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
|
|
13989
|
+
var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
|
|
13990
|
+
var AUDIT_HISTORY_FIELDS = [
|
|
13991
|
+
"id",
|
|
13992
|
+
"task_id",
|
|
13993
|
+
"action",
|
|
13994
|
+
"field",
|
|
13995
|
+
"old_value",
|
|
13996
|
+
"new_value",
|
|
13997
|
+
"agent_id",
|
|
13998
|
+
"created_at",
|
|
13999
|
+
"machine_id"
|
|
14000
|
+
];
|
|
14001
|
+
function auditHistoryRowsAreFieldIdentical(left, right) {
|
|
14002
|
+
return AUDIT_HISTORY_FIELDS.every((field) => {
|
|
14003
|
+
const leftValue = field === "machine_id" ? left[field] ?? null : left[field];
|
|
14004
|
+
const rightValue = field === "machine_id" ? right[field] ?? null : right[field];
|
|
14005
|
+
return leftValue === rightValue;
|
|
14006
|
+
});
|
|
14007
|
+
}
|
|
14008
|
+
function divergentAuditHistoryReplayError(id) {
|
|
14009
|
+
return `${AUDIT_HISTORY_DIVERGENT_REPLAY}: immutable audit_history row ${id} differs from stored row`;
|
|
14010
|
+
}
|
|
14011
|
+
function forbiddenAuditHistoryTombstoneError(id) {
|
|
14012
|
+
return `${AUDIT_HISTORY_TOMBSTONE_FORBIDDEN}: audit_history tombstone ${id} is not allowed`;
|
|
14013
|
+
}
|
|
14014
|
+
|
|
14015
|
+
// src/storage/sqlite-snapshot.ts
|
|
13763
14016
|
var PROJECT_COLUMNS = [
|
|
13764
14017
|
"id",
|
|
13765
14018
|
"name",
|
|
@@ -13960,8 +14213,11 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13960
14213
|
const existingTaskLists = d.query("SELECT id, project_id, slug FROM task_lists").all();
|
|
13961
14214
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
13962
14215
|
}
|
|
14216
|
+
const auditImport = preflightAuditHistoryImport(d, snapshot.auditHistory, snapshot.tombstones ?? []);
|
|
14217
|
+
result.errors.push(...auditImport.errors);
|
|
13963
14218
|
if (result.errors.length > 0)
|
|
13964
14219
|
return result;
|
|
14220
|
+
result.skipped += auditImport.identicalReplayCount;
|
|
13965
14221
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
13966
14222
|
for (const row of rows) {
|
|
13967
14223
|
try {
|
|
@@ -13996,10 +14252,72 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
13996
14252
|
replaceTaskTags(row["id"], row["tags"].filter((tag) => typeof tag === "string"), d);
|
|
13997
14253
|
}
|
|
13998
14254
|
});
|
|
13999
|
-
|
|
14255
|
+
insertAuditHistoryRows(d, auditImport.rowsToInsert, result);
|
|
14000
14256
|
applyTombstones(d, snapshot.tombstones ?? [], result);
|
|
14001
14257
|
return result;
|
|
14002
14258
|
}
|
|
14259
|
+
function preflightAuditHistoryImport(db, rows, tombstones) {
|
|
14260
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
14261
|
+
const rowsToInsert = [];
|
|
14262
|
+
const seen = new Map;
|
|
14263
|
+
let identicalReplayCount = 0;
|
|
14264
|
+
for (const rawRow of rows) {
|
|
14265
|
+
try {
|
|
14266
|
+
const row = asRecord(rawRow);
|
|
14267
|
+
if (typeof row.id !== "string" || !row.id) {
|
|
14268
|
+
throw new Error("task_history row is missing id");
|
|
14269
|
+
}
|
|
14270
|
+
const prior = seen.get(row.id);
|
|
14271
|
+
if (prior) {
|
|
14272
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
14273
|
+
identicalReplayCount += 1;
|
|
14274
|
+
else
|
|
14275
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
14276
|
+
continue;
|
|
14277
|
+
}
|
|
14278
|
+
seen.set(row.id, row);
|
|
14279
|
+
const existing = getAuditHistoryById(db, row.id);
|
|
14280
|
+
if (!existing) {
|
|
14281
|
+
rowsToInsert.push(row);
|
|
14282
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
14283
|
+
identicalReplayCount += 1;
|
|
14284
|
+
} else {
|
|
14285
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
14286
|
+
}
|
|
14287
|
+
} catch (error) {
|
|
14288
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
14289
|
+
}
|
|
14290
|
+
}
|
|
14291
|
+
return { rowsToInsert, identicalReplayCount, errors };
|
|
14292
|
+
}
|
|
14293
|
+
function insertAuditHistoryRows(db, rows, result) {
|
|
14294
|
+
for (const rawRow of rows) {
|
|
14295
|
+
try {
|
|
14296
|
+
const row = asRecord(rawRow);
|
|
14297
|
+
const presentColumns = AUDIT_COLUMNS.filter((column) => (column in row));
|
|
14298
|
+
if (!presentColumns.includes("id"))
|
|
14299
|
+
presentColumns.unshift("id");
|
|
14300
|
+
const placeholders = presentColumns.map(() => "?").join(", ");
|
|
14301
|
+
const values = presentColumns.map((column) => valueForColumn(column, row[column]));
|
|
14302
|
+
const changes = db.run(`INSERT OR IGNORE INTO task_history (${presentColumns.join(", ")}) VALUES (${placeholders})`, values).changes;
|
|
14303
|
+
if (changes > 0) {
|
|
14304
|
+
result.inserted += 1;
|
|
14305
|
+
continue;
|
|
14306
|
+
}
|
|
14307
|
+
const existing = getAuditHistoryById(db, String(row["id"]));
|
|
14308
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
14309
|
+
result.skipped += 1;
|
|
14310
|
+
} else {
|
|
14311
|
+
result.errors.push(divergentAuditHistoryReplayError(String(row["id"])));
|
|
14312
|
+
}
|
|
14313
|
+
} catch (error) {
|
|
14314
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
14315
|
+
}
|
|
14316
|
+
}
|
|
14317
|
+
}
|
|
14318
|
+
function getAuditHistoryById(db, id) {
|
|
14319
|
+
return db.query(`SELECT ${AUDIT_COLUMNS.join(", ")} FROM task_history WHERE id = ? LIMIT 1`).get(id);
|
|
14320
|
+
}
|
|
14003
14321
|
function upsertById(db, table, columns, row, updateClockColumn, acceptEqualClock = true) {
|
|
14004
14322
|
const id = row["id"];
|
|
14005
14323
|
if (typeof id !== "string" || !id)
|
|
@@ -14097,7 +14415,7 @@ function tableForTombstone(objectType) {
|
|
|
14097
14415
|
return "task_templates";
|
|
14098
14416
|
if (objectType === "template_tasks")
|
|
14099
14417
|
return "template_tasks";
|
|
14100
|
-
|
|
14418
|
+
throw new Error(`unsupported storage tombstone object_type: ${String(objectType)}`);
|
|
14101
14419
|
}
|
|
14102
14420
|
function listRows(db, table, columns) {
|
|
14103
14421
|
return db.query(`SELECT ${columns.join(", ")} FROM ${table} ORDER BY id`).all();
|
|
@@ -14229,6 +14547,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14229
14547
|
unlockTask(id, agentId, database());
|
|
14230
14548
|
return true;
|
|
14231
14549
|
},
|
|
14550
|
+
handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
|
|
14232
14551
|
delete: (id) => deleteTask(id, database()),
|
|
14233
14552
|
start: (id, agentId) => startTask(id, agentId, database()),
|
|
14234
14553
|
complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
|
|
@@ -14326,6 +14645,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
14326
14645
|
init_types();
|
|
14327
14646
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
14328
14647
|
init_creator_identity();
|
|
14648
|
+
init_stale_lock_handoff();
|
|
14329
14649
|
|
|
14330
14650
|
// src/storage/postgres-sync.ts
|
|
14331
14651
|
var DEFAULT_TODOS_POSTGRES_SYNC_TABLE = "todos_sync_records";
|
|
@@ -14733,6 +15053,32 @@ function assertSafeIdentifier(value) {
|
|
|
14733
15053
|
|
|
14734
15054
|
// src/storage/postgres-adapter.ts
|
|
14735
15055
|
init_redaction();
|
|
15056
|
+
|
|
15057
|
+
// src/task-manifest/canonical.ts
|
|
15058
|
+
import { createHash as createHash5 } from "crypto";
|
|
15059
|
+
function canonicalize2(value) {
|
|
15060
|
+
if (Array.isArray(value))
|
|
15061
|
+
return value.map(canonicalize2);
|
|
15062
|
+
if (value !== null && typeof value === "object") {
|
|
15063
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry2]) => [key, canonicalize2(entry2)]));
|
|
15064
|
+
}
|
|
15065
|
+
return value;
|
|
15066
|
+
}
|
|
15067
|
+
function canonicalJson(value) {
|
|
15068
|
+
return JSON.stringify(canonicalize2(value));
|
|
15069
|
+
}
|
|
15070
|
+
function canonicalDigest(value) {
|
|
15071
|
+
return createHash5("sha256").update(canonicalJson(value)).digest("hex");
|
|
15072
|
+
}
|
|
15073
|
+
function deterministicUuid(namespace, ...parts) {
|
|
15074
|
+
const bytes = createHash5("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
|
|
15075
|
+
bytes[6] = bytes[6] & 15 | 80;
|
|
15076
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
15077
|
+
const hex = bytes.toString("hex");
|
|
15078
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
15079
|
+
}
|
|
15080
|
+
|
|
15081
|
+
// src/storage/postgres-adapter.ts
|
|
14736
15082
|
function createPostgresTodosStorageAdapter(options) {
|
|
14737
15083
|
const store = new PostgresJsonRecordStore(options);
|
|
14738
15084
|
const adapter = {
|
|
@@ -14761,6 +15107,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
14761
15107
|
getChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
14762
15108
|
lock: (id, agentId) => lockTask2(id, agentId, store),
|
|
14763
15109
|
unlock: (id, agentId) => unlockTask2(id, agentId, store),
|
|
15110
|
+
handoffStaleLock: (input, context) => store.handoffStaleLock(input, context),
|
|
14764
15111
|
getByFingerprint: (fingerprint) => store.getTaskByFingerprint(fingerprint)
|
|
14765
15112
|
},
|
|
14766
15113
|
dependencies: {
|
|
@@ -14907,6 +15254,91 @@ class PostgresJsonRecordStore {
|
|
|
14907
15254
|
LIMIT 1`, [this.service, type, id]);
|
|
14908
15255
|
return result.rows[0] ? payloadRecord2(result.rows[0].payload) : null;
|
|
14909
15256
|
}
|
|
15257
|
+
async handoffStaleLock(input, context = {}) {
|
|
15258
|
+
const prepared = prepareStaleLockHandoff(input);
|
|
15259
|
+
const receipt = buildStaleLockHandoffReceipt(prepared);
|
|
15260
|
+
const history = staleLockHandoffHistory(receipt, this.machineId(context));
|
|
15261
|
+
await this.ensureSchema();
|
|
15262
|
+
const result = await this.options.client.query(`/* todos:stale-lock-handoff-atomic */ WITH
|
|
15263
|
+
target AS MATERIALIZED (
|
|
15264
|
+
SELECT payload
|
|
15265
|
+
FROM ${this.tableName}
|
|
15266
|
+
WHERE service = $1
|
|
15267
|
+
AND object_type = 'tasks'
|
|
15268
|
+
AND object_id = $2
|
|
15269
|
+
AND deleted_at IS NULL
|
|
15270
|
+
FOR UPDATE
|
|
15271
|
+
),
|
|
15272
|
+
updated AS (
|
|
15273
|
+
UPDATE ${this.tableName} AS task_record
|
|
15274
|
+
SET payload = jsonb_set(
|
|
15275
|
+
jsonb_set(
|
|
15276
|
+
jsonb_set(
|
|
15277
|
+
jsonb_set(
|
|
15278
|
+
task_record.payload,
|
|
15279
|
+
'{locked_by}',
|
|
15280
|
+
to_jsonb($6::text),
|
|
15281
|
+
true
|
|
15282
|
+
),
|
|
15283
|
+
'{locked_at}',
|
|
15284
|
+
to_jsonb($7::text),
|
|
15285
|
+
true
|
|
15286
|
+
),
|
|
15287
|
+
'{updated_at}',
|
|
15288
|
+
to_jsonb($7::text),
|
|
15289
|
+
true
|
|
15290
|
+
),
|
|
15291
|
+
'{version}',
|
|
15292
|
+
to_jsonb(COALESCE((task_record.payload->>'version')::integer, 0) + 1),
|
|
15293
|
+
true
|
|
15294
|
+
),
|
|
15295
|
+
updated_at = $7::timestamptz,
|
|
15296
|
+
source_machine_id = $10,
|
|
15297
|
+
version = COALESCE(task_record.version, 0) + 1
|
|
15298
|
+
FROM target
|
|
15299
|
+
WHERE task_record.service = $1
|
|
15300
|
+
AND task_record.object_type = 'tasks'
|
|
15301
|
+
AND task_record.object_id = $2
|
|
15302
|
+
AND task_record.deleted_at IS NULL
|
|
15303
|
+
AND target.payload->>'locked_by' = $3
|
|
15304
|
+
AND target.payload->>'locked_at' = $4
|
|
15305
|
+
AND $4::timestamptz < $5::timestamptz
|
|
15306
|
+
AND COALESCE(target.payload->>'status', '') NOT IN ('completed', 'failed', 'cancelled')
|
|
15307
|
+
RETURNING task_record.payload
|
|
15308
|
+
),
|
|
15309
|
+
audit AS (
|
|
15310
|
+
INSERT INTO ${this.tableName} (
|
|
15311
|
+
service, object_type, object_id, payload, updated_at,
|
|
15312
|
+
deleted_at, source_machine_id, version
|
|
15313
|
+
)
|
|
15314
|
+
SELECT $1, 'audit_history', $8, $9::jsonb, $7::timestamptz,
|
|
15315
|
+
NULL, $10, NULL
|
|
15316
|
+
FROM updated
|
|
15317
|
+
RETURNING payload
|
|
15318
|
+
)
|
|
15319
|
+
SELECT
|
|
15320
|
+
(SELECT payload FROM target) AS current_payload,
|
|
15321
|
+
(SELECT payload FROM updated) AS updated_payload,
|
|
15322
|
+
(SELECT payload FROM audit) AS audit_payload`, [
|
|
15323
|
+
this.service,
|
|
15324
|
+
prepared.task_id,
|
|
15325
|
+
prepared.expected_holder,
|
|
15326
|
+
prepared.expected_lock_version,
|
|
15327
|
+
prepared.stale_cutoff,
|
|
15328
|
+
prepared.new_holder,
|
|
15329
|
+
prepared.operation_timestamp,
|
|
15330
|
+
receipt.receipt_id,
|
|
15331
|
+
jsonbParam(history),
|
|
15332
|
+
this.machineId(context)
|
|
15333
|
+
]);
|
|
15334
|
+
const row = result.rows[0];
|
|
15335
|
+
if (!row?.current_payload)
|
|
15336
|
+
throw new TaskNotFoundError(prepared.task_id);
|
|
15337
|
+
if (!row.updated_payload || !row.audit_payload) {
|
|
15338
|
+
throwStaleLockHandoffConflict(payloadRecord2(row.current_payload), prepared);
|
|
15339
|
+
}
|
|
15340
|
+
return receipt;
|
|
15341
|
+
}
|
|
14910
15342
|
async list(type) {
|
|
14911
15343
|
return (await this.listRecords(type)).map((record) => record.payload);
|
|
14912
15344
|
}
|
|
@@ -15176,6 +15608,28 @@ class PostgresJsonRecordStore {
|
|
|
15176
15608
|
}
|
|
15177
15609
|
return value;
|
|
15178
15610
|
}
|
|
15611
|
+
async insertImmutableAuditHistory(value, context = {}) {
|
|
15612
|
+
await this.ensureSchema();
|
|
15613
|
+
const inserted = await this.options.client.query(`INSERT INTO ${this.tableName} (
|
|
15614
|
+
service, object_type, object_id, payload, updated_at,
|
|
15615
|
+
deleted_at, source_machine_id, version
|
|
15616
|
+
) VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, NULL, $6, NULL)
|
|
15617
|
+
ON CONFLICT (service, object_type, object_id) DO NOTHING
|
|
15618
|
+
RETURNING object_id`, [
|
|
15619
|
+
this.service,
|
|
15620
|
+
"audit_history",
|
|
15621
|
+
value.id,
|
|
15622
|
+
jsonbParam(value),
|
|
15623
|
+
value.created_at,
|
|
15624
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
15625
|
+
]);
|
|
15626
|
+
if (inserted.rows.length > 0)
|
|
15627
|
+
return "inserted";
|
|
15628
|
+
const existing = await this.get("audit_history", value.id);
|
|
15629
|
+
if (existing && auditHistoryRowsAreFieldIdentical(existing, value))
|
|
15630
|
+
return "identical";
|
|
15631
|
+
throw new Error(divergentAuditHistoryReplayError(value.id));
|
|
15632
|
+
}
|
|
15179
15633
|
async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
|
|
15180
15634
|
const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
|
|
15181
15635
|
if (planIds.length === 0)
|
|
@@ -16291,16 +16745,17 @@ async function findCommit(sha, store) {
|
|
|
16291
16745
|
async function addGitRef(input, store, context) {
|
|
16292
16746
|
if (!await store.get("tasks", input.task_id))
|
|
16293
16747
|
throw new Error(`Task not found: ${input.task_id}`);
|
|
16748
|
+
const existing = (await store.list("refs")).filter((ref) => ref.task_id === input.task_id && ref.ref_type === input.ref_type && ref.name === input.name).sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id))[0];
|
|
16294
16749
|
const timestamp2 = new Date().toISOString();
|
|
16295
16750
|
const gitRef = {
|
|
16296
|
-
id:
|
|
16751
|
+
id: existing?.id ?? deterministicUuid("todos:git-ref:v1", input.task_id, input.ref_type, input.name),
|
|
16297
16752
|
task_id: input.task_id,
|
|
16298
16753
|
ref_type: input.ref_type,
|
|
16299
16754
|
name: input.name,
|
|
16300
|
-
url: input.url ?? null,
|
|
16301
|
-
provider: input.provider ?? null,
|
|
16755
|
+
url: input.url ?? existing?.url ?? null,
|
|
16756
|
+
provider: input.provider ?? existing?.provider ?? null,
|
|
16302
16757
|
metadata: input.metadata ?? {},
|
|
16303
|
-
created_at: timestamp2,
|
|
16758
|
+
created_at: existing?.created_at ?? timestamp2,
|
|
16304
16759
|
updated_at: timestamp2
|
|
16305
16760
|
};
|
|
16306
16761
|
await store.upsert("refs", gitRef, context);
|
|
@@ -16667,6 +17122,11 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16667
17122
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
16668
17123
|
if (result.errors.length > 0)
|
|
16669
17124
|
return result;
|
|
17125
|
+
const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
|
|
17126
|
+
result.errors.push(...auditHistory.errors);
|
|
17127
|
+
if (result.errors.length > 0)
|
|
17128
|
+
return result;
|
|
17129
|
+
result.skipped += auditHistory.identical;
|
|
16670
17130
|
const entries = [
|
|
16671
17131
|
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
16672
17132
|
...snapshot.projects.map((row) => ["projects", row]),
|
|
@@ -16675,9 +17135,20 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16675
17135
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
16676
17136
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
16677
17137
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
16678
|
-
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16679
|
-
...snapshot.auditHistory.map((row) => ["audit_history", row])
|
|
17138
|
+
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
16680
17139
|
];
|
|
17140
|
+
for (const row of auditHistory.rowsToInsert) {
|
|
17141
|
+
try {
|
|
17142
|
+
const outcome = await store.insertImmutableAuditHistory(row, context);
|
|
17143
|
+
if (outcome === "inserted")
|
|
17144
|
+
result.inserted += 1;
|
|
17145
|
+
else
|
|
17146
|
+
result.skipped += 1;
|
|
17147
|
+
} catch (error) {
|
|
17148
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
17149
|
+
return result;
|
|
17150
|
+
}
|
|
17151
|
+
}
|
|
16681
17152
|
for (const [type, row] of entries) {
|
|
16682
17153
|
try {
|
|
16683
17154
|
const existing = await store.get(type, row.id);
|
|
@@ -16711,6 +17182,32 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
16711
17182
|
}
|
|
16712
17183
|
return result;
|
|
16713
17184
|
}
|
|
17185
|
+
async function preflightAuditHistoryImport2(rows, tombstones, store) {
|
|
17186
|
+
const errors = tombstones.filter((tombstone) => tombstone.object_type === "audit_history").map((tombstone) => forbiddenAuditHistoryTombstoneError(tombstone.object_id));
|
|
17187
|
+
const rowsToInsert = [];
|
|
17188
|
+
const seen = new Map;
|
|
17189
|
+
let identical = 0;
|
|
17190
|
+
for (const row of rows) {
|
|
17191
|
+
const prior = seen.get(row.id);
|
|
17192
|
+
if (prior) {
|
|
17193
|
+
if (auditHistoryRowsAreFieldIdentical(prior, row))
|
|
17194
|
+
identical += 1;
|
|
17195
|
+
else
|
|
17196
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
17197
|
+
continue;
|
|
17198
|
+
}
|
|
17199
|
+
seen.set(row.id, row);
|
|
17200
|
+
const existing = await store.get("audit_history", row.id);
|
|
17201
|
+
if (!existing) {
|
|
17202
|
+
rowsToInsert.push(row);
|
|
17203
|
+
} else if (auditHistoryRowsAreFieldIdentical(existing, row)) {
|
|
17204
|
+
identical += 1;
|
|
17205
|
+
} else {
|
|
17206
|
+
errors.push(divergentAuditHistoryReplayError(row.id));
|
|
17207
|
+
}
|
|
17208
|
+
}
|
|
17209
|
+
return { rowsToInsert, identical, errors };
|
|
17210
|
+
}
|
|
16714
17211
|
async function requireRecord(type, id, store) {
|
|
16715
17212
|
const record = await store.get(type, id);
|
|
16716
17213
|
if (!record)
|
|
@@ -17769,7 +18266,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
17769
18266
|
return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
|
|
17770
18267
|
}
|
|
17771
18268
|
// src/storage/s3-artifacts.ts
|
|
17772
|
-
import { createHash as
|
|
18269
|
+
import { createHash as createHash6, createHmac as createHmac2 } from "crypto";
|
|
17773
18270
|
function createTodosS3ArtifactStore(options) {
|
|
17774
18271
|
const requestFetch = options.fetch ?? fetch;
|
|
17775
18272
|
const now3 = options.now ?? (() => new Date);
|
|
@@ -17941,7 +18438,7 @@ function toAmzDate(date) {
|
|
|
17941
18438
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
17942
18439
|
}
|
|
17943
18440
|
function sha256Hex(value) {
|
|
17944
|
-
return
|
|
18441
|
+
return createHash6("sha256").update(value).digest("hex");
|
|
17945
18442
|
}
|
|
17946
18443
|
function hmac(key, value) {
|
|
17947
18444
|
return createHmac2("sha256", key).update(value).digest();
|