@hasna/todos 0.15.52 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1993 -0
- package/README.md +356 -42
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai.d.ts.map +1 -1
- package/dist/cli/cloud-router.d.ts +162 -72
- package/dist/cli/cloud-router.d.ts.map +1 -1
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/config-serve-commands.d.ts.map +1 -1
- package/dist/cli/commands/delegate.d.ts.map +1 -1
- package/dist/cli/commands/environment-snapshots.d.ts.map +1 -1
- package/dist/cli/commands/machines-api.d.ts +4 -0
- package/dist/cli/commands/machines-api.d.ts.map +1 -0
- package/dist/cli/commands/machines.d.ts.map +1 -1
- package/dist/cli/commands/mcp-hooks-commands.d.ts.map +1 -1
- package/dist/cli/commands/plan-commands.d.ts +3 -0
- package/dist/cli/commands/plan-commands.d.ts.map +1 -0
- package/dist/cli/commands/plan-template-commands.d.ts.map +1 -1
- package/dist/cli/commands/pr-group-commands.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/storage-commands.d.ts.map +1 -1
- package/dist/cli/commands/task-list-commands.d.ts +3 -0
- package/dist/cli/commands/task-list-commands.d.ts.map +1 -0
- package/dist/cli/commands/template-commands.d.ts +3 -0
- package/dist/cli/commands/template-commands.d.ts.map +1 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/index.js +22072 -15949
- package/dist/cli/machine-api.d.ts +7 -0
- package/dist/cli/machine-api.d.ts.map +1 -0
- package/dist/cli/plan-read-api.d.ts +6 -0
- package/dist/cli/plan-read-api.d.ts.map +1 -0
- package/dist/cli/project-api.d.ts +119 -0
- package/dist/cli/project-api.d.ts.map +1 -0
- package/dist/cli/stage-a.d.ts +25 -5
- package/dist/cli/stage-a.d.ts.map +1 -1
- package/dist/cli/task-query-api.d.ts +21 -0
- package/dist/cli/task-query-api.d.ts.map +1 -0
- package/dist/cli/template-api.d.ts +7 -0
- package/dist/cli/template-api.d.ts.map +1 -0
- package/dist/cli/template-remote.d.ts +90 -0
- package/dist/cli/template-remote.d.ts.map +1 -0
- package/dist/cli-mcp-parity.d.ts.map +1 -1
- package/dist/contracts.js +908 -291
- package/dist/db/agents.d.ts +8 -0
- package/dist/db/agents.d.ts.map +1 -1
- package/dist/db/builtin-templates.d.ts +2 -33
- package/dist/db/builtin-templates.d.ts.map +1 -1
- package/dist/db/database.d.ts +6 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/handoffs.d.ts.map +1 -1
- package/dist/db/identity-mapping.d.ts +25 -0
- package/dist/db/identity-mapping.d.ts.map +1 -1
- package/dist/db/machines.d.ts.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/plans.d.ts.map +1 -1
- package/dist/db/retrospectives.d.ts.map +1 -1
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/task-crud.d.ts.map +1 -1
- package/dist/db/task-lists.d.ts.map +1 -1
- package/dist/db/task-runs.d.ts +2 -0
- package/dist/db/task-runs.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4341 -1756
- package/dist/lib/assignee-validation.d.ts.map +1 -1
- package/dist/lib/builtin-template-library.d.ts +32 -0
- package/dist/lib/builtin-template-library.d.ts.map +1 -0
- package/dist/lib/cli-reference.d.ts.map +1 -1
- package/dist/lib/config.d.ts +10 -13
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/db-backup.d.ts +1 -1
- package/dist/lib/db-backup.d.ts.map +1 -1
- package/dist/lib/env.d.ts +18 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/environment-snapshots.d.ts.map +1 -1
- package/dist/lib/feature-manifest.d.ts.map +1 -1
- package/dist/lib/gatherer.d.ts.map +1 -1
- package/dist/lib/headless-boundaries.d.ts +4 -3
- package/dist/lib/headless-boundaries.d.ts.map +1 -1
- package/dist/lib/local-encryption.d.ts +12 -0
- package/dist/lib/local-encryption.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +104 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +20 -1
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/plan-artifacts.d.ts +1 -0
- package/dist/lib/plan-artifacts.d.ts.map +1 -1
- package/dist/lib/plan-client-boundary.d.ts +2 -0
- package/dist/lib/plan-client-boundary.d.ts.map +1 -0
- package/dist/lib/plan-schedule.d.ts +6 -0
- package/dist/lib/plan-schedule.d.ts.map +1 -0
- package/dist/lib/project-panel.d.ts +6 -0
- package/dist/lib/project-panel.d.ts.map +1 -1
- package/dist/lib/project-receipt.d.ts +3 -0
- package/dist/lib/project-receipt.d.ts.map +1 -0
- package/dist/lib/sandbox-profiles.d.ts.map +1 -1
- package/dist/lib/shared-plan-artifacts.d.ts +14 -0
- package/dist/lib/shared-plan-artifacts.d.ts.map +1 -0
- package/dist/lib/sync.d.ts.map +1 -1
- package/dist/lib/task-list-client-boundary.d.ts +3 -0
- package/dist/lib/task-list-client-boundary.d.ts.map +1 -0
- package/dist/lib/task-routing.d.ts.map +1 -1
- package/dist/lib/template-client-boundary.d.ts +2 -0
- package/dist/lib/template-client-boundary.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +53 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +50124 -60838
- package/dist/mcp/plan-api.d.ts +6 -0
- package/dist/mcp/plan-api.d.ts.map +1 -0
- package/dist/mcp/remote-authority.d.ts +49 -0
- package/dist/mcp/remote-authority.d.ts.map +1 -0
- package/dist/mcp/task-coordination-api.d.ts +15 -0
- package/dist/mcp/task-coordination-api.d.ts.map +1 -0
- package/dist/mcp/task-list-api.d.ts +8 -0
- package/dist/mcp/task-list-api.d.ts.map +1 -0
- package/dist/mcp/token-utils.d.ts.map +1 -1
- package/dist/mcp/tools/agents.d.ts.map +1 -1
- package/dist/mcp/tools/environment-snapshots.d.ts.map +1 -1
- package/dist/mcp/tools/machines.d.ts.map +1 -1
- package/dist/mcp/tools/task-adv-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-auto-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-crud.d.ts.map +1 -1
- package/dist/mcp/tools/task-meta-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-resources.d.ts.map +1 -1
- package/dist/mcp/tools/templates.d.ts.map +1 -1
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +45 -19
- package/dist/project-registration.js +3349 -645
- package/dist/registry.js +909 -292
- package/dist/release-provenance.json +6 -6
- package/dist/sdk/client.d.ts +53 -4
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +1253 -1088
- package/dist/sdk/resolve.d.ts +101 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/types.d.ts +17 -2
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/v1.generated.d.ts +334 -3
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/auth-posture.d.ts +66 -7
- package/dist/server/auth-posture.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +2 -1
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +24758 -16687
- package/dist/server/openapi.d.ts +2514 -469
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/port.d.ts +2 -2
- package/dist/server/port.d.ts.map +1 -1
- package/dist/server/rate-limit-config.d.ts +6 -0
- package/dist/server/rate-limit-config.d.ts.map +1 -0
- package/dist/server/routes.d.ts +3 -6
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/serve.d.ts +2 -6
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts +3 -1
- package/dist/server/v1.d.ts.map +1 -1
- package/dist/storage/atomic-project-migration.d.ts +53 -0
- package/dist/storage/atomic-project-migration.d.ts.map +1 -0
- package/dist/storage/index.d.ts +4 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +135 -3
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/machine-registry.d.ts +36 -0
- package/dist/storage/machine-registry.d.ts.map +1 -0
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts +2 -2
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/run-artifact-creation-upload.d.ts +50 -0
- package/dist/storage/run-artifact-creation-upload.d.ts.map +1 -0
- package/dist/storage/s3-artifact-sync.d.ts +18 -0
- package/dist/storage/s3-artifact-sync.d.ts.map +1 -1
- package/dist/storage/sqlite-snapshot.d.ts +2 -0
- package/dist/storage/sqlite-snapshot.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +3154 -350
- package/dist/task-manifest/postgres.d.ts +1 -1
- package/dist/task-manifest/sqlite.d.ts +1 -1
- package/dist/task-manifest.js +250 -73
- package/dist/task-subtree-transfer.js +198 -4
- package/dist/testing.d.ts +90 -29
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +34 -10
- package/dist/types/index.d.ts +43 -4
- package/dist/types/index.d.ts.map +1 -1
- package/docs/PLAN_API.md +93 -0
- package/docs/TASK_LIST_API.md +58 -0
- package/docs/TASK_QUERY_API.md +14 -0
- package/docs/TEMPLATE_API.md +18 -0
- package/docs/native-storage.md +405 -0
- package/package.json +18 -15
- package/postinstall.js +52 -1
- package/dashboard/dist/assets/index-BNQ4gJua.js +0 -342
- package/dashboard/dist/assets/index-DjzvHUWt.css +0 -1
- package/dashboard/dist/index.html +0 -13
- package/dashboard/dist/logo.jpg +0 -0
- package/dist/mcp/tools/code-tools.d.ts +0 -15
- package/dist/mcp/tools/code-tools.d.ts.map +0 -1
package/dist/storage.js
CHANGED
|
@@ -253,7 +253,7 @@ function isBlockingDependencyStatus(status) {
|
|
|
253
253
|
function isTerminalStatus(status) {
|
|
254
254
|
return status === "completed" || status === "failed" || status === "cancelled";
|
|
255
255
|
}
|
|
256
|
-
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;
|
|
256
|
+
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, InputValidationError;
|
|
257
257
|
var init_types = __esm(() => {
|
|
258
258
|
TASK_STATUSES = [
|
|
259
259
|
"pending",
|
|
@@ -268,7 +268,7 @@ var init_types = __esm(() => {
|
|
|
268
268
|
"high",
|
|
269
269
|
"critical"
|
|
270
270
|
];
|
|
271
|
-
PLAN_STATUSES = ["active", "completed", "archived"];
|
|
271
|
+
PLAN_STATUSES = ["active", "completed", "archived", "planning", "cancelled"];
|
|
272
272
|
VersionConflictError = class VersionConflictError extends Error {
|
|
273
273
|
taskId;
|
|
274
274
|
expectedVersion;
|
|
@@ -457,6 +457,72 @@ var init_types = __esm(() => {
|
|
|
457
457
|
this.name = "DispatchNotFoundError";
|
|
458
458
|
}
|
|
459
459
|
};
|
|
460
|
+
InputValidationError = class InputValidationError extends Error {
|
|
461
|
+
static code = "INVALID_INPUT";
|
|
462
|
+
code = InputValidationError.code;
|
|
463
|
+
suggestion;
|
|
464
|
+
constructor(message, suggestion) {
|
|
465
|
+
super(message);
|
|
466
|
+
this.name = "InputValidationError";
|
|
467
|
+
this.suggestion = suggestion;
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// src/lib/local-opt-in.ts
|
|
473
|
+
import {
|
|
474
|
+
clientTransportEnvKeys,
|
|
475
|
+
credentialOverrideEnvKey,
|
|
476
|
+
credentialPointerEnvKey,
|
|
477
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
478
|
+
} from "@hasna/contracts/client";
|
|
479
|
+
function isTodosLocalOptIn(env = process.env) {
|
|
480
|
+
return TODOS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
|
|
481
|
+
}
|
|
482
|
+
function todosAuthorityEnvKeys() {
|
|
483
|
+
const keys = clientTransportEnvKeys("todos");
|
|
484
|
+
return [
|
|
485
|
+
...keys.apiUrlKeys,
|
|
486
|
+
...keys.apiKeyKeys,
|
|
487
|
+
credentialOverrideEnvKey("todos"),
|
|
488
|
+
credentialPointerEnvKey("todos"),
|
|
489
|
+
CREDENTIAL_PROFILE_ENV_KEY
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
function hasTodosEnvAuthorityIntent(env = process.env) {
|
|
493
|
+
return todosAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
|
|
494
|
+
}
|
|
495
|
+
function selectsTodosLocalStore(env = process.env) {
|
|
496
|
+
return !hasTodosEnvAuthorityIntent(env) && isTodosLocalOptIn(env);
|
|
497
|
+
}
|
|
498
|
+
function todosResolverEnv(env) {
|
|
499
|
+
const blanks = todosAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
|
|
500
|
+
if (blanks.length === 0)
|
|
501
|
+
return env;
|
|
502
|
+
const next = { ...env };
|
|
503
|
+
for (const key of blanks)
|
|
504
|
+
delete next[key];
|
|
505
|
+
return next;
|
|
506
|
+
}
|
|
507
|
+
function isAmbientTodosEnv(env) {
|
|
508
|
+
if (typeof process !== "undefined" && env === process.env)
|
|
509
|
+
return true;
|
|
510
|
+
return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
|
|
511
|
+
}
|
|
512
|
+
function todosResolverInputs(env, credentials = {}) {
|
|
513
|
+
const normalised = todosResolverEnv(env);
|
|
514
|
+
if (normalised === env)
|
|
515
|
+
return { env: normalised, credentials };
|
|
516
|
+
const keychain = { ...credentials.keychain };
|
|
517
|
+
if (keychain.enabled === undefined && keychain.run === undefined) {
|
|
518
|
+
keychain.enabled = isAmbientTodosEnv(env);
|
|
519
|
+
}
|
|
520
|
+
return { env: normalised, credentials: { ...credentials, keychain } };
|
|
521
|
+
}
|
|
522
|
+
var TODOS_LOCAL_OPT_IN_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
|
|
523
|
+
var init_local_opt_in = __esm(() => {
|
|
524
|
+
TODOS_LOCAL_OPT_IN_ENV_KEYS = ["HASNA_TODOS_LOCAL", "TODOS_LOCAL"];
|
|
525
|
+
CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
|
|
460
526
|
});
|
|
461
527
|
|
|
462
528
|
// src/project-registration/schema.ts
|
|
@@ -2028,16 +2094,18 @@ var init_migrations = __esm(() => {
|
|
|
2028
2094
|
SET leaf_task_id = COALESCE(leaf_task_id, (
|
|
2029
2095
|
SELECT attempt.leaf_task_id
|
|
2030
2096
|
FROM pr_group_attempts AS attempt
|
|
2097
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
2031
2098
|
WHERE attempt.group_id = pr_groups.id
|
|
2032
|
-
ORDER BY CASE WHEN attempt.id =
|
|
2099
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
2033
2100
|
attempt.created_at ASC, attempt.id ASC
|
|
2034
2101
|
LIMIT 1
|
|
2035
2102
|
)),
|
|
2036
2103
|
branch = COALESCE(branch, (
|
|
2037
2104
|
SELECT attempt.branch
|
|
2038
2105
|
FROM pr_group_attempts AS attempt
|
|
2106
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
2039
2107
|
WHERE attempt.group_id = pr_groups.id
|
|
2040
|
-
ORDER BY CASE WHEN attempt.id =
|
|
2108
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
2041
2109
|
attempt.created_at ASC, attempt.id ASC
|
|
2042
2110
|
LIMIT 1
|
|
2043
2111
|
));
|
|
@@ -2060,16 +2128,18 @@ var init_migrations = __esm(() => {
|
|
|
2060
2128
|
SET pr_number = COALESCE(pr_number, (
|
|
2061
2129
|
SELECT attempt.pr_number
|
|
2062
2130
|
FROM pr_group_attempts AS attempt
|
|
2131
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
2063
2132
|
WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
|
|
2064
|
-
ORDER BY CASE WHEN attempt.id =
|
|
2133
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
2065
2134
|
attempt.created_at ASC, attempt.id ASC
|
|
2066
2135
|
LIMIT 1
|
|
2067
2136
|
)),
|
|
2068
2137
|
base_sha = COALESCE(base_sha, (
|
|
2069
2138
|
SELECT attempt.base_sha
|
|
2070
2139
|
FROM pr_group_attempts AS attempt
|
|
2140
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
2071
2141
|
WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
|
|
2072
|
-
ORDER BY CASE WHEN attempt.id =
|
|
2142
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
2073
2143
|
attempt.created_at ASC, attempt.id ASC
|
|
2074
2144
|
LIMIT 1
|
|
2075
2145
|
));
|
|
@@ -2108,7 +2178,7 @@ var init_migrations = __esm(() => {
|
|
|
2108
2178
|
));
|
|
2109
2179
|
|
|
2110
2180
|
INSERT OR IGNORE INTO _migrations (id)
|
|
2111
|
-
SELECT
|
|
2181
|
+
SELECT 67
|
|
2112
2182
|
WHERE NOT EXISTS (
|
|
2113
2183
|
SELECT 1 FROM pr_groups WHERE leaf_task_id IS NULL OR branch IS NULL
|
|
2114
2184
|
)
|
|
@@ -2313,32 +2383,52 @@ var init_migrations = __esm(() => {
|
|
|
2313
2383
|
});
|
|
2314
2384
|
|
|
2315
2385
|
// src/db/schema.ts
|
|
2386
|
+
function runPrGroupLineageMigration(db, migration, rebuild) {
|
|
2387
|
+
if (db.inTransaction)
|
|
2388
|
+
throw new Error("PR-group migration requires its own transaction");
|
|
2389
|
+
const priorForeignKeys = db.query("PRAGMA foreign_keys").get().foreign_keys;
|
|
2390
|
+
try {
|
|
2391
|
+
if (rebuild)
|
|
2392
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
2393
|
+
db.exec("BEGIN IMMEDIATE");
|
|
2394
|
+
for (const statement of migration.split(/;\s*(?:\n|$)/).map((value) => value.trim()).filter(Boolean)) {
|
|
2395
|
+
if (/^(?:PRAGMA foreign_keys\s*=|BEGIN IMMEDIATE$|COMMIT$)/i.test(statement))
|
|
2396
|
+
continue;
|
|
2397
|
+
db.run(statement);
|
|
2398
|
+
}
|
|
2399
|
+
const completed = db.query("SELECT id FROM _migrations WHERE id = ?").get(rebuild ? 68 : 67);
|
|
2400
|
+
if (!completed)
|
|
2401
|
+
throw new Error("PR-group lineage backfill is incomplete; migration rolled back");
|
|
2402
|
+
if (rebuild && db.query("PRAGMA foreign_key_check").all().length) {
|
|
2403
|
+
throw new Error("PR-group migration failed foreign-key integrity verification");
|
|
2404
|
+
}
|
|
2405
|
+
db.exec("COMMIT");
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
if (db.inTransaction)
|
|
2408
|
+
db.exec("ROLLBACK");
|
|
2409
|
+
throw error;
|
|
2410
|
+
} finally {
|
|
2411
|
+
db.exec(`PRAGMA foreign_keys = ${priorForeignKeys ? "ON" : "OFF"}`);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2316
2414
|
function runMigrations(db) {
|
|
2317
|
-
let
|
|
2318
|
-
|
|
2415
|
+
let currentLevel = 0;
|
|
2416
|
+
try {
|
|
2417
|
+
currentLevel = db.query("SELECT MAX(id) as max_id FROM _migrations").get()?.max_id ?? 0;
|
|
2418
|
+
} catch {}
|
|
2419
|
+
for (let index = currentLevel;index < MIGRATIONS.length; index++) {
|
|
2319
2420
|
try {
|
|
2320
|
-
|
|
2421
|
+
if (index === 66 || index === 67)
|
|
2422
|
+
runPrGroupLineageMigration(db, MIGRATIONS[index], index === 67);
|
|
2423
|
+
else
|
|
2424
|
+
db.exec(MIGRATIONS[index]);
|
|
2321
2425
|
} catch (error) {
|
|
2322
|
-
|
|
2426
|
+
if (db.inTransaction)
|
|
2323
2427
|
db.exec("ROLLBACK");
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
strictMigrationFailure = error;
|
|
2327
|
-
}
|
|
2328
|
-
};
|
|
2329
|
-
try {
|
|
2330
|
-
const result = db.query("SELECT MAX(id) as max_id FROM _migrations").get();
|
|
2331
|
-
const currentLevel = result?.max_id ?? 0;
|
|
2332
|
-
for (let i = currentLevel;i < MIGRATIONS.length; i++) {
|
|
2333
|
-
executeMigration(MIGRATIONS[i], i);
|
|
2334
|
-
}
|
|
2335
|
-
} catch {
|
|
2336
|
-
for (const [index, migration] of MIGRATIONS.entries()) {
|
|
2337
|
-
executeMigration(migration, index);
|
|
2428
|
+
if (index + 1 >= 67)
|
|
2429
|
+
throw error;
|
|
2338
2430
|
}
|
|
2339
2431
|
}
|
|
2340
|
-
if (strictMigrationFailure)
|
|
2341
|
-
throw strictMigrationFailure;
|
|
2342
2432
|
ensureSchema(db);
|
|
2343
2433
|
}
|
|
2344
2434
|
function planSlugBase(value) {
|
|
@@ -3650,16 +3740,18 @@ function ensureSchema(db) {
|
|
|
3650
3740
|
SET leaf_task_id = COALESCE(leaf_task_id, (
|
|
3651
3741
|
SELECT attempt.leaf_task_id
|
|
3652
3742
|
FROM pr_group_attempts AS attempt
|
|
3743
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
3653
3744
|
WHERE attempt.group_id = pr_groups.id
|
|
3654
|
-
ORDER BY CASE WHEN attempt.id =
|
|
3745
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
3655
3746
|
attempt.created_at ASC, attempt.id ASC
|
|
3656
3747
|
LIMIT 1
|
|
3657
3748
|
)),
|
|
3658
3749
|
branch = COALESCE(branch, (
|
|
3659
3750
|
SELECT attempt.branch
|
|
3660
3751
|
FROM pr_group_attempts AS attempt
|
|
3752
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
3661
3753
|
WHERE attempt.group_id = pr_groups.id
|
|
3662
|
-
ORDER BY CASE WHEN attempt.id =
|
|
3754
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
3663
3755
|
attempt.created_at ASC, attempt.id ASC
|
|
3664
3756
|
LIMIT 1
|
|
3665
3757
|
));
|
|
@@ -3680,16 +3772,18 @@ function ensureSchema(db) {
|
|
|
3680
3772
|
SET pr_number = COALESCE(pr_number, (
|
|
3681
3773
|
SELECT attempt.pr_number
|
|
3682
3774
|
FROM pr_group_attempts AS attempt
|
|
3775
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
3683
3776
|
WHERE attempt.group_id = pr_groups.id AND attempt.pr_number IS NOT NULL
|
|
3684
|
-
ORDER BY CASE WHEN attempt.id =
|
|
3777
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
3685
3778
|
attempt.created_at ASC, attempt.id ASC
|
|
3686
3779
|
LIMIT 1
|
|
3687
3780
|
)),
|
|
3688
3781
|
base_sha = COALESCE(base_sha, (
|
|
3689
3782
|
SELECT attempt.base_sha
|
|
3690
3783
|
FROM pr_group_attempts AS attempt
|
|
3784
|
+
JOIN pr_groups AS owner ON owner.id = attempt.group_id
|
|
3691
3785
|
WHERE attempt.group_id = pr_groups.id AND attempt.base_sha IS NOT NULL
|
|
3692
|
-
ORDER BY CASE WHEN attempt.id =
|
|
3786
|
+
ORDER BY CASE WHEN attempt.id = owner.active_attempt_id THEN 0 ELSE 1 END,
|
|
3693
3787
|
attempt.created_at ASC, attempt.id ASC
|
|
3694
3788
|
LIMIT 1
|
|
3695
3789
|
));
|
|
@@ -3801,6 +3895,28 @@ var init_schema = __esm(() => {
|
|
|
3801
3895
|
init_migrations();
|
|
3802
3896
|
});
|
|
3803
3897
|
|
|
3898
|
+
// src/lib/env.ts
|
|
3899
|
+
var alias = (canonical, legacy) => process.env[canonical] ?? process.env[legacy], env;
|
|
3900
|
+
var init_env = __esm(() => {
|
|
3901
|
+
env = {
|
|
3902
|
+
profile: () => alias("HASNA_TODOS_PROFILE", "TODOS_PROFILE"),
|
|
3903
|
+
toolGroups: () => alias("HASNA_TODOS_TOOL_GROUPS", "TODOS_TOOL_GROUPS"),
|
|
3904
|
+
machineId: () => alias("HASNA_TODOS_MACHINE_ID", "TODOS_MACHINE_ID"),
|
|
3905
|
+
machineName: () => alias("HASNA_TODOS_MACHINE_NAME", "TODOS_MACHINE_NAME"),
|
|
3906
|
+
rateLimitMax: () => alias("HASNA_TODOS_RATE_LIMIT_MAX", "TODOS_RATE_LIMIT_MAX"),
|
|
3907
|
+
trustProxy: () => alias("HASNA_TODOS_TRUST_PROXY", "TODOS_TRUST_PROXY"),
|
|
3908
|
+
autoProject: () => alias("HASNA_TODOS_AUTO_PROJECT", "TODOS_AUTO_PROJECT"),
|
|
3909
|
+
aiFormat: () => alias("HASNA_TODOS_AI_FORMAT", "TODOS_AI_FORMAT"),
|
|
3910
|
+
syncAgents: () => alias("HASNA_TODOS_SYNC_AGENTS", "TODOS_SYNC_AGENTS"),
|
|
3911
|
+
taskListId: () => alias("HASNA_TODOS_TASK_LIST_ID", "TODOS_TASK_LIST_ID"),
|
|
3912
|
+
claudeTaskList: () => alias("HASNA_TODOS_CLAUDE_TASK_LIST", "TODOS_CLAUDE_TASK_LIST"),
|
|
3913
|
+
sandboxProfilesPath: () => alias("HASNA_TODOS_SANDBOX_PROFILES_PATH", "TODOS_SANDBOX_PROFILES_PATH"),
|
|
3914
|
+
seatRosterPath: () => alias("HASNA_TODOS_SEAT_ROSTER_PATH", "TODOS_SEAT_ROSTER_PATH"),
|
|
3915
|
+
delegateNoticeChannel: () => alias("HASNA_TODOS_DELEGATE_NOTICE_CHANNEL", "TODOS_DELEGATE_NOTICE_CHANNEL"),
|
|
3916
|
+
delegateNotifyBin: () => alias("HASNA_TODOS_DELEGATE_NOTIFY_BIN", "TODOS_DELEGATE_NOTIFY_BIN")
|
|
3917
|
+
};
|
|
3918
|
+
});
|
|
3919
|
+
|
|
3804
3920
|
// src/db/machines.ts
|
|
3805
3921
|
import { existsSync } from "fs";
|
|
3806
3922
|
import { hostname as osHostname, platform as osPlatform, arch as osArch } from "os";
|
|
@@ -3863,7 +3979,7 @@ function extractTopology(machine) {
|
|
|
3863
3979
|
}
|
|
3864
3980
|
function getOrCreateLocalMachine(db) {
|
|
3865
3981
|
const d = db || getDatabase();
|
|
3866
|
-
const name =
|
|
3982
|
+
const name = env.machineName() || osHostname();
|
|
3867
3983
|
const host = osHostname();
|
|
3868
3984
|
const plat = osPlatform();
|
|
3869
3985
|
const existing = d.query("SELECT * FROM machines WHERE name = ?").get(name);
|
|
@@ -3932,7 +4048,7 @@ function registerMachine(name, opts, db) {
|
|
|
3932
4048
|
}
|
|
3933
4049
|
function updateMachineHeartbeat(idOrName, opts = {}, db) {
|
|
3934
4050
|
const d = db || getDatabase();
|
|
3935
|
-
const key = idOrName ||
|
|
4051
|
+
const key = idOrName || env.machineName() || osHostname();
|
|
3936
4052
|
const row = d.query("SELECT * FROM machines WHERE id = ? OR name = ?").get(key, key);
|
|
3937
4053
|
if (!row) {
|
|
3938
4054
|
return registerMachine(key, {
|
|
@@ -4084,6 +4200,7 @@ function backfillMachineId(db, force = false) {
|
|
|
4084
4200
|
var _machineId = null, TABLES_WITH_MACHINE_ID;
|
|
4085
4201
|
var init_machines = __esm(() => {
|
|
4086
4202
|
init_database();
|
|
4203
|
+
init_env();
|
|
4087
4204
|
TABLES_WITH_MACHINE_ID = [
|
|
4088
4205
|
"projects",
|
|
4089
4206
|
"tasks",
|
|
@@ -4207,20 +4324,17 @@ function ensureAgentIdentitySchema(db) {
|
|
|
4207
4324
|
SELECT RAISE(ABORT, 'IDENTITY_SOURCE_LINEAGE_IMMUTABLE');
|
|
4208
4325
|
END;
|
|
4209
4326
|
|
|
4327
|
+
-- Drop first so an older definition is replaced rather than silently
|
|
4328
|
+
-- retained (the upgrade path). AGENT_IDENTITY_HISTORY_TRIGGER_DDL then
|
|
4329
|
+
-- re-creates them with CREATE TRIGGER IF NOT EXISTS. The two statements
|
|
4330
|
+
-- do different jobs and both are kept: the DROP is what refreshes a
|
|
4331
|
+
-- stale definition, and the IF NOT EXISTS is what keeps the CREATE safe
|
|
4332
|
+
-- on its own \u2014 so a caller that reaches it without a preceding DROP, in
|
|
4333
|
+
-- its own connection or transaction, gets a no-op instead of
|
|
4334
|
+
-- "trigger ... already exists".
|
|
4210
4335
|
DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_immutable;
|
|
4211
|
-
CREATE TRIGGER trg_agent_identity_mapping_history_immutable
|
|
4212
|
-
BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
|
|
4213
|
-
ON agent_identity_source_mappings
|
|
4214
|
-
BEGIN
|
|
4215
|
-
SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
|
|
4216
|
-
END;
|
|
4217
|
-
|
|
4218
4336
|
DROP TRIGGER IF EXISTS trg_agent_identity_mapping_history_append_only;
|
|
4219
|
-
|
|
4220
|
-
BEFORE DELETE ON agent_identity_source_mappings
|
|
4221
|
-
BEGIN
|
|
4222
|
-
SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
|
|
4223
|
-
END;
|
|
4337
|
+
${AGENT_IDENTITY_HISTORY_TRIGGER_DDL}
|
|
4224
4338
|
`);
|
|
4225
4339
|
db.run("INSERT OR IGNORE INTO _migrations (id) VALUES (?)", [IDENTITY_MIGRATION_ID]);
|
|
4226
4340
|
});
|
|
@@ -4260,7 +4374,20 @@ function storeAgentAlias(agentId, label, aliasKind, status, db) {
|
|
|
4260
4374
|
function recordAgentAlias(agentId, label, db) {
|
|
4261
4375
|
return storeAgentAlias(agentId, label, "historical", "active", db);
|
|
4262
4376
|
}
|
|
4263
|
-
var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65
|
|
4377
|
+
var IDENTITY_PROJECTION_CONTRACT, IDENTITY_MIGRATION_ID = 65, AGENT_IDENTITY_HISTORY_TRIGGER_DDL = `
|
|
4378
|
+
CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_immutable
|
|
4379
|
+
BEFORE UPDATE OF local_agent_id, observed_label, evidence, mapping_basis, status, revision, created_at
|
|
4380
|
+
ON agent_identity_source_mappings
|
|
4381
|
+
BEGIN
|
|
4382
|
+
SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
|
|
4383
|
+
END;
|
|
4384
|
+
|
|
4385
|
+
CREATE TRIGGER IF NOT EXISTS trg_agent_identity_mapping_history_append_only
|
|
4386
|
+
BEFORE DELETE ON agent_identity_source_mappings
|
|
4387
|
+
BEGIN
|
|
4388
|
+
SELECT RAISE(ABORT, 'IDENTITY_MAPPING_HISTORY_IMMUTABLE');
|
|
4389
|
+
END;
|
|
4390
|
+
`;
|
|
4264
4391
|
var init_identity_mapping = __esm(() => {
|
|
4265
4392
|
init_types();
|
|
4266
4393
|
init_types();
|
|
@@ -4282,108 +4409,106 @@ var init_identity_mapping = __esm(() => {
|
|
|
4282
4409
|
});
|
|
4283
4410
|
});
|
|
4284
4411
|
|
|
4285
|
-
//
|
|
4412
|
+
// src/lib/paths.ts
|
|
4413
|
+
import { existsSync as existsSync2 } from "fs";
|
|
4286
4414
|
import { homedir } from "os";
|
|
4287
|
-
import { join } from "path";
|
|
4288
|
-
|
|
4415
|
+
import { isAbsolute, join, resolve as resolve2 } from "path";
|
|
4416
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
4417
|
+
import { join as pathsResolverJoin } from "path";
|
|
4418
|
+
function pathsResolverAssertApp(app) {
|
|
4289
4419
|
if (typeof app !== "string" || app.length === 0) {
|
|
4290
4420
|
throw new TypeError("paths: app must be a non-empty string");
|
|
4291
4421
|
}
|
|
4292
|
-
if (!
|
|
4422
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
4293
4423
|
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
4294
4424
|
}
|
|
4295
4425
|
}
|
|
4296
|
-
function
|
|
4297
|
-
|
|
4298
|
-
}
|
|
4299
|
-
|
|
4300
|
-
const value = envOf(options)[KIND_ENV[kind]];
|
|
4301
|
-
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4302
|
-
}
|
|
4303
|
-
function isMacOS(platform) {
|
|
4304
|
-
return platform === "darwin";
|
|
4426
|
+
function pathsResolverAssertKind(kind) {
|
|
4427
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
4428
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
4429
|
+
}
|
|
4305
4430
|
}
|
|
4306
|
-
function
|
|
4307
|
-
|
|
4308
|
-
|
|
4431
|
+
function pathsResolverBaseDir(kind, options) {
|
|
4432
|
+
pathsResolverAssertKind(kind);
|
|
4433
|
+
const env2 = options.env ?? process.env;
|
|
4434
|
+
const override = env2[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
4435
|
+
if (typeof override === "string" && override.length > 0)
|
|
4309
4436
|
return override;
|
|
4310
|
-
const home = options.home ??
|
|
4437
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
4311
4438
|
const platform = options.platform ?? process.platform;
|
|
4312
|
-
if (
|
|
4439
|
+
if (platform === "darwin") {
|
|
4313
4440
|
switch (kind) {
|
|
4314
4441
|
case "config":
|
|
4315
4442
|
case "data":
|
|
4316
|
-
return
|
|
4443
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
4317
4444
|
case "cache":
|
|
4318
|
-
return
|
|
4445
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
4319
4446
|
case "state":
|
|
4320
|
-
return
|
|
4447
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
4321
4448
|
}
|
|
4322
4449
|
}
|
|
4323
4450
|
switch (kind) {
|
|
4324
4451
|
case "config":
|
|
4325
|
-
return
|
|
4452
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
4326
4453
|
case "data":
|
|
4327
|
-
return
|
|
4454
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
4328
4455
|
case "state":
|
|
4329
|
-
return
|
|
4456
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
4330
4457
|
case "cache":
|
|
4331
|
-
return
|
|
4458
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
4332
4459
|
}
|
|
4333
4460
|
}
|
|
4334
|
-
function
|
|
4335
|
-
|
|
4336
|
-
const appSegment = options.internal === true ?
|
|
4337
|
-
return
|
|
4461
|
+
function pathsResolverResolve(kind, options) {
|
|
4462
|
+
pathsResolverAssertApp(options.app);
|
|
4463
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
4464
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
4338
4465
|
}
|
|
4339
4466
|
function dataDir(options) {
|
|
4340
|
-
return
|
|
4467
|
+
return pathsResolverResolve("data", options);
|
|
4341
4468
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4351
|
-
});
|
|
4352
|
-
|
|
4353
|
-
// src/lib/paths.ts
|
|
4354
|
-
import { existsSync as existsSync2 } from "fs";
|
|
4355
|
-
import { homedir as homedir2 } from "os";
|
|
4356
|
-
import { join as join2, resolve as resolve2 } from "path";
|
|
4357
|
-
function effectiveHome(env = process.env) {
|
|
4358
|
-
return env.HOME || env.USERPROFILE || homedir2();
|
|
4469
|
+
function effectiveHome(env2 = process.env) {
|
|
4470
|
+
return env2.HOME || env2.USERPROFILE || homedir();
|
|
4471
|
+
}
|
|
4472
|
+
function hasnaHomeRoot(env2 = process.env) {
|
|
4473
|
+
const override = env2["HASNA_HOME"]?.trim();
|
|
4474
|
+
if (override && isAbsolute(override))
|
|
4475
|
+
return override;
|
|
4476
|
+
return join(effectiveHome(env2), ".hasna");
|
|
4359
4477
|
}
|
|
4360
|
-
function legacyHomeDir(
|
|
4361
|
-
return
|
|
4478
|
+
function legacyHomeDir(env2 = process.env) {
|
|
4479
|
+
return join(hasnaHomeRoot(env2), "todos");
|
|
4362
4480
|
}
|
|
4363
|
-
function resolverHome(
|
|
4364
|
-
return dataDir({ app: "todos", home: effectiveHome(
|
|
4481
|
+
function resolverHome(env2 = process.env) {
|
|
4482
|
+
return dataDir({ app: "todos", home: effectiveHome(env2), env: env2 });
|
|
4365
4483
|
}
|
|
4366
|
-
function adoptResolverHome(resolved,
|
|
4367
|
-
const dataOverride =
|
|
4484
|
+
function adoptResolverHome(resolved, env2 = process.env) {
|
|
4485
|
+
const dataOverride = env2.HASNA_DATA_HOME;
|
|
4368
4486
|
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
4369
4487
|
return true;
|
|
4370
|
-
return existsSync2(
|
|
4488
|
+
return existsSync2(join(resolved, "todos.db")) || existsSync2(join(resolved, "config.json"));
|
|
4371
4489
|
}
|
|
4372
|
-
function getTodosDir(
|
|
4373
|
-
const resolved = resolverHome(
|
|
4374
|
-
return resolve2(adoptResolverHome(resolved,
|
|
4490
|
+
function getTodosDir(env2 = process.env) {
|
|
4491
|
+
const resolved = resolverHome(env2);
|
|
4492
|
+
return resolve2(adoptResolverHome(resolved, env2) ? resolved : legacyHomeDir(env2));
|
|
4375
4493
|
}
|
|
4494
|
+
var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE;
|
|
4376
4495
|
var init_paths = __esm(() => {
|
|
4377
|
-
|
|
4496
|
+
PATHS_RESOLVER_KIND_ENV = {
|
|
4497
|
+
config: "HASNA_CONFIG_HOME",
|
|
4498
|
+
data: "HASNA_DATA_HOME",
|
|
4499
|
+
state: "HASNA_STATE_HOME",
|
|
4500
|
+
cache: "HASNA_CACHE_HOME"
|
|
4501
|
+
};
|
|
4502
|
+
PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4378
4503
|
});
|
|
4379
4504
|
|
|
4380
4505
|
// src/lib/sync-utils.ts
|
|
4381
4506
|
import { existsSync as existsSync3, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
4382
4507
|
import { createHash } from "crypto";
|
|
4383
|
-
import { homedir as
|
|
4384
|
-
import { join as
|
|
4508
|
+
import { homedir as homedir2 } from "os";
|
|
4509
|
+
import { join as join2 } from "path";
|
|
4385
4510
|
function getHomeDir() {
|
|
4386
|
-
return process.env["HOME"] || process.env["USERPROFILE"] ||
|
|
4511
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
4387
4512
|
}
|
|
4388
4513
|
function getTodosGlobalDir() {
|
|
4389
4514
|
return getTodosDir();
|
|
@@ -4409,14 +4534,14 @@ function writeJsonFile(path, data) {
|
|
|
4409
4534
|
`);
|
|
4410
4535
|
}
|
|
4411
4536
|
function readHighWaterMark(dir) {
|
|
4412
|
-
const path =
|
|
4537
|
+
const path = join2(dir, ".highwatermark");
|
|
4413
4538
|
if (!existsSync3(path))
|
|
4414
4539
|
return 1;
|
|
4415
4540
|
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
4416
4541
|
return isNaN(val) ? 1 : val;
|
|
4417
4542
|
}
|
|
4418
4543
|
function writeHighWaterMark(dir, value) {
|
|
4419
|
-
writeFileSync(
|
|
4544
|
+
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
4420
4545
|
}
|
|
4421
4546
|
function getFileMtimeMs(path) {
|
|
4422
4547
|
try {
|
|
@@ -4563,20 +4688,23 @@ __export(exports_database, {
|
|
|
4563
4688
|
resolvePartialId: () => resolvePartialId,
|
|
4564
4689
|
resolveAssignedToAliases: () => resolveAssignedToAliases,
|
|
4565
4690
|
resetDatabase: () => resetDatabase,
|
|
4691
|
+
refuseLocalStore: () => refuseLocalStore,
|
|
4566
4692
|
now: () => now,
|
|
4567
4693
|
lowerInClause: () => lowerInClause,
|
|
4568
4694
|
lockExpiryCutoff: () => lockExpiryCutoff,
|
|
4569
4695
|
isLockExpired: () => isLockExpired,
|
|
4696
|
+
isLocalStoreRefused: () => isLocalStoreRefused,
|
|
4570
4697
|
getDatabasePath: () => getDatabasePath,
|
|
4571
4698
|
getDatabase: () => getDatabase,
|
|
4572
4699
|
closeDatabase: () => closeDatabase,
|
|
4573
4700
|
clearExpiredLocks: () => clearExpiredLocks,
|
|
4574
4701
|
assignedToAliasSet: () => assignedToAliasSet,
|
|
4702
|
+
allowLocalStore: () => allowLocalStore,
|
|
4575
4703
|
LOCK_EXPIRY_MINUTES: () => LOCK_EXPIRY_MINUTES
|
|
4576
4704
|
});
|
|
4577
4705
|
import { Database } from "bun:sqlite";
|
|
4578
4706
|
import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
|
|
4579
|
-
import { dirname, join as
|
|
4707
|
+
import { dirname, join as join3, resolve as resolve3 } from "path";
|
|
4580
4708
|
function isInMemoryDb(path) {
|
|
4581
4709
|
return path === ":memory:" || path.startsWith("file::memory:");
|
|
4582
4710
|
}
|
|
@@ -4585,7 +4713,7 @@ function findNearestProjectDb(startDir) {
|
|
|
4585
4713
|
const stopAt = gitRoot ? resolve3(gitRoot) : resolve3(startDir);
|
|
4586
4714
|
let dir = resolve3(startDir);
|
|
4587
4715
|
while (true) {
|
|
4588
|
-
const candidate =
|
|
4716
|
+
const candidate = join3(dir, ".hasna", "todos", "todos.db");
|
|
4589
4717
|
if (existsSync4(candidate))
|
|
4590
4718
|
return candidate;
|
|
4591
4719
|
if (dir === stopAt)
|
|
@@ -4600,7 +4728,7 @@ function findNearestProjectDb(startDir) {
|
|
|
4600
4728
|
function findGitRoot(startDir) {
|
|
4601
4729
|
let dir = resolve3(startDir);
|
|
4602
4730
|
while (true) {
|
|
4603
|
-
if (existsSync4(
|
|
4731
|
+
if (existsSync4(join3(dir, ".git")))
|
|
4604
4732
|
return dir;
|
|
4605
4733
|
const parent = dirname(dir);
|
|
4606
4734
|
if (parent === dir)
|
|
@@ -4610,7 +4738,7 @@ function findGitRoot(startDir) {
|
|
|
4610
4738
|
return null;
|
|
4611
4739
|
}
|
|
4612
4740
|
function getGlobalDbPath() {
|
|
4613
|
-
return
|
|
4741
|
+
return join3(getTodosGlobalDir(), "todos.db");
|
|
4614
4742
|
}
|
|
4615
4743
|
function hasExplicitProjectArg(args = process.argv.slice(2)) {
|
|
4616
4744
|
return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
|
|
@@ -4648,7 +4776,7 @@ function getDbPath() {
|
|
|
4648
4776
|
if (process.env["TODOS_DB_SCOPE"] === "project") {
|
|
4649
4777
|
const gitRoot = findGitRoot(cwd);
|
|
4650
4778
|
if (gitRoot && canCreateScopedProjectDb()) {
|
|
4651
|
-
return
|
|
4779
|
+
return join3(gitRoot, ".hasna", "todos", "todos.db");
|
|
4652
4780
|
}
|
|
4653
4781
|
}
|
|
4654
4782
|
return getGlobalDbPath();
|
|
@@ -4688,7 +4816,21 @@ function maybeInstallShadowCapture(db) {
|
|
|
4688
4816
|
console.error(`[todos] shadow capture install failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4689
4817
|
}
|
|
4690
4818
|
}
|
|
4819
|
+
function refuseLocalStore(message) {
|
|
4820
|
+
localStoreRefusal = message;
|
|
4821
|
+
}
|
|
4822
|
+
function allowLocalStore() {
|
|
4823
|
+
localStoreRefusal = null;
|
|
4824
|
+
}
|
|
4825
|
+
function isLocalStoreRefused() {
|
|
4826
|
+
return localStoreRefusal !== null;
|
|
4827
|
+
}
|
|
4691
4828
|
function getDatabase(dbPath) {
|
|
4829
|
+
if (localStoreRefusal !== null)
|
|
4830
|
+
throw new Error(localStoreRefusal);
|
|
4831
|
+
if (dbPath === undefined && !selectsTodosLocalStore()) {
|
|
4832
|
+
throw new Error("API_DATABASE_FALLBACK_FORBIDDEN: this operation must use the shared Todos API; implicit SQLite access is unavailable");
|
|
4833
|
+
}
|
|
4692
4834
|
const path = dbPath || getDbPath();
|
|
4693
4835
|
if (_db && _dbPath === path)
|
|
4694
4836
|
return _db;
|
|
@@ -4826,8 +4968,9 @@ function lowerInClause(column, values, params) {
|
|
|
4826
4968
|
params.push(...values.map((v) => v.toLowerCase()));
|
|
4827
4969
|
return `LOWER(${column}) IN (${values.map(() => "?").join(",")})`;
|
|
4828
4970
|
}
|
|
4829
|
-
var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, ALLOWED_TABLES;
|
|
4971
|
+
var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, localStoreRefusal = null, ALLOWED_TABLES;
|
|
4830
4972
|
var init_database = __esm(() => {
|
|
4973
|
+
init_local_opt_in();
|
|
4831
4974
|
init_schema();
|
|
4832
4975
|
init_machines();
|
|
4833
4976
|
init_identity_mapping();
|
|
@@ -4838,9 +4981,9 @@ var init_database = __esm(() => {
|
|
|
4838
4981
|
|
|
4839
4982
|
// src/lib/config.ts
|
|
4840
4983
|
import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
|
4841
|
-
import { dirname as dirname2, join as
|
|
4984
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
4842
4985
|
function getConfigPath() {
|
|
4843
|
-
return
|
|
4986
|
+
return join4(getTodosGlobalDir(), "config.json");
|
|
4844
4987
|
}
|
|
4845
4988
|
function normalizeAgent(agent) {
|
|
4846
4989
|
return agent.trim().toLowerCase();
|
|
@@ -4891,21 +5034,6 @@ function normalizeApiUrl(value) {
|
|
|
4891
5034
|
return null;
|
|
4892
5035
|
return trimmed.replace(/\/+$/, "");
|
|
4893
5036
|
}
|
|
4894
|
-
function getLocalApiConfig(env = process.env) {
|
|
4895
|
-
const config = loadConfig();
|
|
4896
|
-
const envApiUrl = normalizeApiUrl(env["TODOS_URL"]);
|
|
4897
|
-
const configApiUrl = normalizeApiUrl(config.apiUrl);
|
|
4898
|
-
const apiUrl = envApiUrl ?? configApiUrl;
|
|
4899
|
-
const apiKey = env["TODOS_API_KEY"] || config.apiKey || null;
|
|
4900
|
-
return {
|
|
4901
|
-
apiUrl,
|
|
4902
|
-
apiKey,
|
|
4903
|
-
source: {
|
|
4904
|
-
apiUrl: envApiUrl ? "TODOS_URL" : configApiUrl ? "config" : "none",
|
|
4905
|
-
apiKey: env["TODOS_API_KEY"] ? "TODOS_API_KEY" : config.apiKey ? "config" : "none"
|
|
4906
|
-
}
|
|
4907
|
-
};
|
|
4908
|
-
}
|
|
4909
5037
|
function getSyncAgentsFromConfig() {
|
|
4910
5038
|
const config = loadConfig();
|
|
4911
5039
|
const agents = config.sync_agents;
|
|
@@ -5756,11 +5884,11 @@ function writeAllowed(profile, root, writePath) {
|
|
|
5756
5884
|
return isPathInside(scopeRoot, target);
|
|
5757
5885
|
});
|
|
5758
5886
|
}
|
|
5759
|
-
function redactedEnvKeys(profile,
|
|
5760
|
-
if (!
|
|
5887
|
+
function redactedEnvKeys(profile, env2) {
|
|
5888
|
+
if (!env2)
|
|
5761
5889
|
return [];
|
|
5762
5890
|
const patterns = unique2([...DEFAULT_ENV_REDACTIONS, ...profile.env_redactions]).map((item) => item.toUpperCase());
|
|
5763
|
-
return Object.keys(
|
|
5891
|
+
return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
|
|
5764
5892
|
}
|
|
5765
5893
|
function checkWorkspacePermission(input = {}) {
|
|
5766
5894
|
const status = getWorkspaceTrustStatus(input.path || process.cwd());
|
|
@@ -5898,18 +6026,18 @@ function profileByName(name, path) {
|
|
|
5898
6026
|
const resolved = normalizePath2(path);
|
|
5899
6027
|
return profiles.find((profile) => isPathInside2(profile.root, resolved)) || defaultProfile2("default", resolved);
|
|
5900
6028
|
}
|
|
5901
|
-
function redactedEnvKeys2(profile,
|
|
5902
|
-
if (!
|
|
6029
|
+
function redactedEnvKeys2(profile, env2) {
|
|
6030
|
+
if (!env2)
|
|
5903
6031
|
return [];
|
|
5904
6032
|
const patterns = unique3([...DEFAULT_ENV_REDACTIONS2, ...profile.env_redactions]).map((item) => item.toUpperCase());
|
|
5905
|
-
return Object.keys(
|
|
6033
|
+
return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
|
|
5906
6034
|
}
|
|
5907
|
-
function omittedEnvKeys(profile,
|
|
5908
|
-
if (!
|
|
6035
|
+
function omittedEnvKeys(profile, env2) {
|
|
6036
|
+
if (!env2)
|
|
5909
6037
|
return [];
|
|
5910
6038
|
if (profile.env_allowlist.includes("*"))
|
|
5911
6039
|
return [];
|
|
5912
|
-
return Object.keys(
|
|
6040
|
+
return Object.keys(env2).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
|
|
5913
6041
|
}
|
|
5914
6042
|
function resolveFromCwd(cwd, path) {
|
|
5915
6043
|
return normalizePath2(path.startsWith("/") ? path : `${cwd}/${path}`);
|
|
@@ -6292,15 +6420,23 @@ var init_event_hooks = __esm(() => {
|
|
|
6292
6420
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
6293
6421
|
});
|
|
6294
6422
|
|
|
6295
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
6423
|
+
// node_modules/.bun/@hasna+events@0.1.17/node_modules/@hasna/events/dist/index.js
|
|
6296
6424
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6297
6425
|
import { Buffer as Buffer2 } from "buffer";
|
|
6426
|
+
import { existsSync as existsSync22 } from "fs";
|
|
6427
|
+
import { join as join32 } from "path";
|
|
6298
6428
|
import { existsSync as existsSync6 } from "fs";
|
|
6299
|
-
import { homedir as
|
|
6300
|
-
import { join as
|
|
6429
|
+
import { homedir as homedir22 } from "os";
|
|
6430
|
+
import { join as join22, resolve as resolve8 } from "path";
|
|
6431
|
+
import { homedir as homedir3 } from "os";
|
|
6432
|
+
import { join as join5 } from "path";
|
|
6301
6433
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
6434
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
6435
|
+
import { isIP } from "net";
|
|
6302
6436
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
6303
6437
|
import { spawn } from "child_process";
|
|
6438
|
+
import { request as nodeHttpRequest } from "http";
|
|
6439
|
+
import { request as nodeHttpsRequest } from "https";
|
|
6304
6440
|
import { randomUUID as randomUUID22 } from "crypto";
|
|
6305
6441
|
function getPathValue(input, path) {
|
|
6306
6442
|
return path.split(".").reduce((value, part) => {
|
|
@@ -6397,8 +6533,93 @@ function channelMatchesEvent(channel, event) {
|
|
|
6397
6533
|
return true;
|
|
6398
6534
|
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
6399
6535
|
}
|
|
6536
|
+
function assertApp(app) {
|
|
6537
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
6538
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
6539
|
+
}
|
|
6540
|
+
if (!APP_SLUG_RE.test(app)) {
|
|
6541
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
6542
|
+
}
|
|
6543
|
+
}
|
|
6544
|
+
function envOf(options) {
|
|
6545
|
+
return options.env ?? process.env;
|
|
6546
|
+
}
|
|
6547
|
+
function envValue(options, kind) {
|
|
6548
|
+
const value = envOf(options)[KIND_ENV[kind]];
|
|
6549
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
6550
|
+
}
|
|
6551
|
+
function isMacOS(platform) {
|
|
6552
|
+
return platform === "darwin";
|
|
6553
|
+
}
|
|
6554
|
+
function baseDir(kind, options) {
|
|
6555
|
+
const override = envValue(options, kind);
|
|
6556
|
+
if (override)
|
|
6557
|
+
return override;
|
|
6558
|
+
const home = options.home ?? homedir3();
|
|
6559
|
+
const platform = options.platform ?? process.platform;
|
|
6560
|
+
if (isMacOS(platform)) {
|
|
6561
|
+
switch (kind) {
|
|
6562
|
+
case "config":
|
|
6563
|
+
case "data":
|
|
6564
|
+
return join5(home, "Library", "Application Support", "Hasna");
|
|
6565
|
+
case "cache":
|
|
6566
|
+
return join5(home, "Library", "Caches", "Hasna");
|
|
6567
|
+
case "state":
|
|
6568
|
+
return join5(home, "Library", "Logs", "Hasna");
|
|
6569
|
+
}
|
|
6570
|
+
}
|
|
6571
|
+
switch (kind) {
|
|
6572
|
+
case "config":
|
|
6573
|
+
return join5(home, ".config", "hasna");
|
|
6574
|
+
case "data":
|
|
6575
|
+
return join5(home, ".local", "share", "hasna");
|
|
6576
|
+
case "state":
|
|
6577
|
+
return join5(home, ".local", "state", "hasna");
|
|
6578
|
+
case "cache":
|
|
6579
|
+
return join5(home, ".cache", "hasna");
|
|
6580
|
+
}
|
|
6581
|
+
}
|
|
6582
|
+
function resolvePath(kind, options) {
|
|
6583
|
+
assertApp(options.app);
|
|
6584
|
+
const appSegment = options.internal === true ? join5("internal", options.app) : options.app;
|
|
6585
|
+
return join5(baseDir(kind, options), appSegment);
|
|
6586
|
+
}
|
|
6587
|
+
function dataDir2(options) {
|
|
6588
|
+
return resolvePath("data", options);
|
|
6589
|
+
}
|
|
6590
|
+
function effectiveHome2() {
|
|
6591
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir22();
|
|
6592
|
+
}
|
|
6593
|
+
function legacyHomeDir2() {
|
|
6594
|
+
return join22(effectiveHome2(), ".hasna", "events");
|
|
6595
|
+
}
|
|
6596
|
+
function resolverHome2() {
|
|
6597
|
+
return dataDir2({ app: "events", home: effectiveHome2() || undefined });
|
|
6598
|
+
}
|
|
6599
|
+
function adoptResolverHome2(resolved, env2 = process.env) {
|
|
6600
|
+
const dataOverride = env2.HASNA_DATA_HOME;
|
|
6601
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
6602
|
+
return true;
|
|
6603
|
+
return existsSync6(join22(resolved, EVENTS_STORE_SENTINEL_FILE));
|
|
6604
|
+
}
|
|
6605
|
+
function exactEventsHome() {
|
|
6606
|
+
const dir = process.env[HASNA_EVENTS_DIR_ENV];
|
|
6607
|
+
if (dir && dir.trim())
|
|
6608
|
+
return dir.trim();
|
|
6609
|
+
const home = process.env[HASNA_EVENTS_HOME_ENV];
|
|
6610
|
+
if (home && home.trim())
|
|
6611
|
+
return home.trim();
|
|
6612
|
+
return;
|
|
6613
|
+
}
|
|
6614
|
+
function getEventsHome() {
|
|
6615
|
+
const exact = exactEventsHome();
|
|
6616
|
+
if (exact)
|
|
6617
|
+
return resolve8(exact);
|
|
6618
|
+
const resolved = resolverHome2();
|
|
6619
|
+
return adoptResolverHome2(resolved) ? resolve8(resolved) : resolve8(legacyHomeDir2());
|
|
6620
|
+
}
|
|
6400
6621
|
function getEventsDataDir(override) {
|
|
6401
|
-
return override ||
|
|
6622
|
+
return override || getEventsHome();
|
|
6402
6623
|
}
|
|
6403
6624
|
|
|
6404
6625
|
class JsonEventsStore {
|
|
@@ -6407,12 +6628,12 @@ class JsonEventsStore {
|
|
|
6407
6628
|
channelsPath;
|
|
6408
6629
|
eventsPath;
|
|
6409
6630
|
deliveriesPath;
|
|
6410
|
-
constructor(
|
|
6411
|
-
this.dataDir =
|
|
6412
|
-
this.runtime = localJsonRuntime(
|
|
6413
|
-
this.channelsPath =
|
|
6414
|
-
this.eventsPath =
|
|
6415
|
-
this.deliveriesPath =
|
|
6631
|
+
constructor(dataDir22 = getEventsDataDir()) {
|
|
6632
|
+
this.dataDir = dataDir22;
|
|
6633
|
+
this.runtime = localJsonRuntime(dataDir22);
|
|
6634
|
+
this.channelsPath = join32(dataDir22, "channels.json");
|
|
6635
|
+
this.eventsPath = join32(dataDir22, "events.json");
|
|
6636
|
+
this.deliveriesPath = join32(dataDir22, "deliveries.json");
|
|
6416
6637
|
}
|
|
6417
6638
|
async init() {
|
|
6418
6639
|
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
@@ -6529,7 +6750,7 @@ class JsonEventsStore {
|
|
|
6529
6750
|
};
|
|
6530
6751
|
}
|
|
6531
6752
|
async ensureArrayFile(path) {
|
|
6532
|
-
if (!
|
|
6753
|
+
if (!existsSync22(path)) {
|
|
6533
6754
|
await writeFile(path, `[]
|
|
6534
6755
|
`, { encoding: "utf-8", mode: 384 });
|
|
6535
6756
|
}
|
|
@@ -6559,7 +6780,7 @@ class JsonEventsStore {
|
|
|
6559
6780
|
});
|
|
6560
6781
|
}
|
|
6561
6782
|
}
|
|
6562
|
-
function localJsonRuntime(
|
|
6783
|
+
function localJsonRuntime(dataDir22 = getEventsDataDir()) {
|
|
6563
6784
|
return {
|
|
6564
6785
|
mode: "local-files",
|
|
6565
6786
|
name: "json-events-store",
|
|
@@ -6572,7 +6793,7 @@ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
|
|
|
6572
6793
|
durable: true,
|
|
6573
6794
|
idempotency: "best-effort-local",
|
|
6574
6795
|
replayCursors: true,
|
|
6575
|
-
description: `Local JSON files in ${
|
|
6796
|
+
description: `Local JSON files in ${dataDir22}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
6576
6797
|
};
|
|
6577
6798
|
}
|
|
6578
6799
|
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
@@ -6643,6 +6864,177 @@ function signPayload(secret, timestamp2, body) {
|
|
|
6643
6864
|
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp2, body)).digest("hex");
|
|
6644
6865
|
return `sha256=${digest}`;
|
|
6645
6866
|
}
|
|
6867
|
+
function isPrivateAddress(address) {
|
|
6868
|
+
const normalized = stripZoneId(address);
|
|
6869
|
+
const version = isIP(normalized);
|
|
6870
|
+
if (version === 4) {
|
|
6871
|
+
const integer = ipv4ToInt(normalized);
|
|
6872
|
+
if (integer === undefined)
|
|
6873
|
+
return true;
|
|
6874
|
+
return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
|
|
6875
|
+
}
|
|
6876
|
+
if (version === 6) {
|
|
6877
|
+
const groups = ipv6Groups(normalized);
|
|
6878
|
+
if (!groups)
|
|
6879
|
+
return true;
|
|
6880
|
+
for (const prefix of IPV6_SPECIAL_PREFIXES) {
|
|
6881
|
+
if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
|
|
6882
|
+
continue;
|
|
6883
|
+
if (prefix.bits === 96 && groups[5] === 65535) {
|
|
6884
|
+
return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
|
|
6885
|
+
}
|
|
6886
|
+
if (prefix.bits === 16 && groups[0] === 8194) {
|
|
6887
|
+
return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
|
|
6888
|
+
}
|
|
6889
|
+
return true;
|
|
6890
|
+
}
|
|
6891
|
+
return false;
|
|
6892
|
+
}
|
|
6893
|
+
return true;
|
|
6894
|
+
}
|
|
6895
|
+
async function resolveWebhookTarget(url, policy = {}) {
|
|
6896
|
+
const hostname = normalizeHostname(url.hostname);
|
|
6897
|
+
const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
|
|
6898
|
+
if (allowlist.includes(hostname)) {
|
|
6899
|
+
const version2 = isIP(hostname);
|
|
6900
|
+
if (version2 === 4 || version2 === 6) {
|
|
6901
|
+
return { hostname, addresses: [hostname] };
|
|
6902
|
+
}
|
|
6903
|
+
const lookup2 = policy.lookup ?? defaultTargetLookup;
|
|
6904
|
+
let resolved2;
|
|
6905
|
+
try {
|
|
6906
|
+
resolved2 = await lookup2(hostname);
|
|
6907
|
+
} catch {
|
|
6908
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
6909
|
+
}
|
|
6910
|
+
if (!Array.isArray(resolved2) || resolved2.length === 0) {
|
|
6911
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
6912
|
+
}
|
|
6913
|
+
const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
|
|
6914
|
+
return { hostname, addresses };
|
|
6915
|
+
}
|
|
6916
|
+
const version = isIP(hostname);
|
|
6917
|
+
if (version === 4 || version === 6) {
|
|
6918
|
+
if (isPrivateAddress(hostname)) {
|
|
6919
|
+
throw new Error(`Webhook target ${hostname} is a private or special-use address`);
|
|
6920
|
+
}
|
|
6921
|
+
return { hostname, addresses: [hostname] };
|
|
6922
|
+
}
|
|
6923
|
+
const lookup = policy.lookup ?? defaultTargetLookup;
|
|
6924
|
+
let resolved;
|
|
6925
|
+
try {
|
|
6926
|
+
resolved = await lookup(hostname);
|
|
6927
|
+
} catch {
|
|
6928
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
6929
|
+
}
|
|
6930
|
+
if (!Array.isArray(resolved) || resolved.length === 0) {
|
|
6931
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
6932
|
+
}
|
|
6933
|
+
const allowed = [];
|
|
6934
|
+
for (const entry of resolved) {
|
|
6935
|
+
const address = normalizeHostname(entry.address);
|
|
6936
|
+
if (isPrivateAddress(address)) {
|
|
6937
|
+
if (allowlist.includes(address)) {
|
|
6938
|
+
allowed.push(address);
|
|
6939
|
+
continue;
|
|
6940
|
+
}
|
|
6941
|
+
throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
|
|
6942
|
+
}
|
|
6943
|
+
allowed.push(address);
|
|
6944
|
+
}
|
|
6945
|
+
if (allowed.length === 0) {
|
|
6946
|
+
throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
|
|
6947
|
+
}
|
|
6948
|
+
return { hostname, addresses: allowed };
|
|
6949
|
+
}
|
|
6950
|
+
function normalizeMaxRedirects(value) {
|
|
6951
|
+
if (value === undefined)
|
|
6952
|
+
return DEFAULT_MAX_REDIRECTS;
|
|
6953
|
+
if (!Number.isInteger(value) || value < 0)
|
|
6954
|
+
throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
|
|
6955
|
+
return value;
|
|
6956
|
+
}
|
|
6957
|
+
function normalizeHostname(hostname) {
|
|
6958
|
+
const lower = hostname.toLowerCase();
|
|
6959
|
+
if (lower.startsWith("[") && lower.endsWith("]"))
|
|
6960
|
+
return lower.slice(1, -1);
|
|
6961
|
+
return lower;
|
|
6962
|
+
}
|
|
6963
|
+
function stripZoneId(address) {
|
|
6964
|
+
const percent = address.indexOf("%");
|
|
6965
|
+
return percent === -1 ? address : address.slice(0, percent);
|
|
6966
|
+
}
|
|
6967
|
+
function ipv4ToInt(address) {
|
|
6968
|
+
const parts = address.split(".");
|
|
6969
|
+
if (parts.length !== 4)
|
|
6970
|
+
return;
|
|
6971
|
+
let value = 0;
|
|
6972
|
+
for (const part of parts) {
|
|
6973
|
+
if (!/^\d{1,3}$/.test(part))
|
|
6974
|
+
return;
|
|
6975
|
+
const octet = Number(part);
|
|
6976
|
+
if (octet > 255)
|
|
6977
|
+
return;
|
|
6978
|
+
value = value << 8 | octet;
|
|
6979
|
+
}
|
|
6980
|
+
return value >>> 0;
|
|
6981
|
+
}
|
|
6982
|
+
function ipv4IntToString(integer) {
|
|
6983
|
+
return [
|
|
6984
|
+
integer >>> 24 & 255,
|
|
6985
|
+
integer >>> 16 & 255,
|
|
6986
|
+
integer >>> 8 & 255,
|
|
6987
|
+
integer & 255
|
|
6988
|
+
].join(".");
|
|
6989
|
+
}
|
|
6990
|
+
function ipv6Groups(address) {
|
|
6991
|
+
const raw = stripZoneId(address);
|
|
6992
|
+
const doubleColon = raw.indexOf("::");
|
|
6993
|
+
const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
|
|
6994
|
+
const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
|
|
6995
|
+
const parseGroups = (text) => {
|
|
6996
|
+
if (text === "")
|
|
6997
|
+
return [];
|
|
6998
|
+
const out = [];
|
|
6999
|
+
for (const part of text.split(":")) {
|
|
7000
|
+
if (part.includes(".")) {
|
|
7001
|
+
const v4 = ipv4ToInt(part);
|
|
7002
|
+
if (v4 === undefined)
|
|
7003
|
+
return;
|
|
7004
|
+
out.push(v4 >>> 16 & 65535, v4 & 65535);
|
|
7005
|
+
} else {
|
|
7006
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
7007
|
+
return;
|
|
7008
|
+
out.push(parseInt(part, 16));
|
|
7009
|
+
}
|
|
7010
|
+
}
|
|
7011
|
+
return out;
|
|
7012
|
+
};
|
|
7013
|
+
const head = parseGroups(headText);
|
|
7014
|
+
if (!head)
|
|
7015
|
+
return;
|
|
7016
|
+
const tail = parseGroups(tailText);
|
|
7017
|
+
if (!tail)
|
|
7018
|
+
return;
|
|
7019
|
+
const total = head.length + tail.length;
|
|
7020
|
+
if (doubleColon === -1) {
|
|
7021
|
+
return total === 8 ? head : undefined;
|
|
7022
|
+
}
|
|
7023
|
+
if (total >= 8)
|
|
7024
|
+
return;
|
|
7025
|
+
return [...head, ...new Array(8 - total).fill(0), ...tail];
|
|
7026
|
+
}
|
|
7027
|
+
function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
|
|
7028
|
+
let remaining = prefixBits;
|
|
7029
|
+
for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
|
|
7030
|
+
const take = Math.min(16, remaining);
|
|
7031
|
+
const mask = 65535 << 16 - take & 65535;
|
|
7032
|
+
if ((groups[index] & mask) !== (prefixGroups[index] & mask))
|
|
7033
|
+
return false;
|
|
7034
|
+
remaining -= take;
|
|
7035
|
+
}
|
|
7036
|
+
return true;
|
|
7037
|
+
}
|
|
6646
7038
|
function now2() {
|
|
6647
7039
|
return new Date().toISOString();
|
|
6648
7040
|
}
|
|
@@ -6673,9 +7065,18 @@ function buildWebhookRequest(event, channel, options = {}) {
|
|
|
6673
7065
|
}
|
|
6674
7066
|
return { body, headers };
|
|
6675
7067
|
}
|
|
7068
|
+
function normalizeWebhookUrl(raw) {
|
|
7069
|
+
const url = new URL(raw);
|
|
7070
|
+
if (url.username !== "" || url.password !== "") {
|
|
7071
|
+
url.username = "";
|
|
7072
|
+
url.password = "";
|
|
7073
|
+
}
|
|
7074
|
+
return url.toString();
|
|
7075
|
+
}
|
|
6676
7076
|
async function dispatchWebhook(event, channel, options = {}) {
|
|
6677
7077
|
if (!channel.webhook)
|
|
6678
7078
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7079
|
+
const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
|
|
6679
7080
|
const startedAt = now2();
|
|
6680
7081
|
let secret = channel.webhook.secret;
|
|
6681
7082
|
if (channel.webhook.secretRef) {
|
|
@@ -6692,10 +7093,14 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6692
7093
|
}
|
|
6693
7094
|
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
6694
7095
|
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
7096
|
+
const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
|
|
7097
|
+
if (validateTargets) {
|
|
7098
|
+
return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
|
|
7099
|
+
}
|
|
6695
7100
|
const controller = new AbortController;
|
|
6696
7101
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
6697
7102
|
try {
|
|
6698
|
-
const response = await (options.fetchImpl ?? fetch)(
|
|
7103
|
+
const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
|
|
6699
7104
|
method: "POST",
|
|
6700
7105
|
headers,
|
|
6701
7106
|
body,
|
|
@@ -6723,6 +7128,130 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6723
7128
|
clearTimeout(timeout);
|
|
6724
7129
|
}
|
|
6725
7130
|
}
|
|
7131
|
+
function isRedirectStatus(status) {
|
|
7132
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
7133
|
+
}
|
|
7134
|
+
function redirectKeepsBody(status) {
|
|
7135
|
+
return status === 307 || status === 308;
|
|
7136
|
+
}
|
|
7137
|
+
async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
|
|
7138
|
+
const isHttps = target.protocol === "https:";
|
|
7139
|
+
if (!isHttps && target.protocol !== "http:") {
|
|
7140
|
+
throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
|
|
7141
|
+
}
|
|
7142
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
7143
|
+
const port = target.port ? Number(target.port) : defaultPort;
|
|
7144
|
+
const requestOptions = {
|
|
7145
|
+
hostname: target.hostname,
|
|
7146
|
+
port,
|
|
7147
|
+
path: `${target.pathname}${target.search}`,
|
|
7148
|
+
method,
|
|
7149
|
+
headers,
|
|
7150
|
+
...tls?.ca ? { ca: tls.ca } : {},
|
|
7151
|
+
lookup: (hostname, _options, callback) => {
|
|
7152
|
+
const entries = addresses.map((address) => ({
|
|
7153
|
+
address,
|
|
7154
|
+
family: address.includes(":") ? 6 : 4
|
|
7155
|
+
}));
|
|
7156
|
+
callback(null, entries);
|
|
7157
|
+
}
|
|
7158
|
+
};
|
|
7159
|
+
return new Promise((resolve22, reject) => {
|
|
7160
|
+
const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
|
|
7161
|
+
const onAbort = () => {
|
|
7162
|
+
const error = new Error("The operation was aborted.");
|
|
7163
|
+
error.name = "AbortError";
|
|
7164
|
+
request.destroy(error);
|
|
7165
|
+
};
|
|
7166
|
+
if (signal.aborted)
|
|
7167
|
+
onAbort();
|
|
7168
|
+
else
|
|
7169
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7170
|
+
request.on("error", reject);
|
|
7171
|
+
if (body !== undefined)
|
|
7172
|
+
request.write(body);
|
|
7173
|
+
request.end();
|
|
7174
|
+
function onResponse(response) {
|
|
7175
|
+
const chunks = [];
|
|
7176
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
7177
|
+
response.on("error", reject);
|
|
7178
|
+
response.on("end", () => {
|
|
7179
|
+
const headersRecord = {};
|
|
7180
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
7181
|
+
if (typeof value === "string")
|
|
7182
|
+
headersRecord[name] = value;
|
|
7183
|
+
else if (Array.isArray(value))
|
|
7184
|
+
headersRecord[name] = value.join(", ");
|
|
7185
|
+
}
|
|
7186
|
+
resolve22(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
|
|
7187
|
+
});
|
|
7188
|
+
}
|
|
7189
|
+
});
|
|
7190
|
+
}
|
|
7191
|
+
async function dispatchValidatedWebhook(event, channel, input) {
|
|
7192
|
+
const { body, headers, startedAt, options } = input;
|
|
7193
|
+
const webhook = channel.webhook;
|
|
7194
|
+
if (!webhook)
|
|
7195
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7196
|
+
const policy = options.webhookTargetPolicy ?? {};
|
|
7197
|
+
const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
|
|
7198
|
+
const controller = new AbortController;
|
|
7199
|
+
const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
|
|
7200
|
+
try {
|
|
7201
|
+
let target = new URL(normalizeWebhookUrl(webhook.url));
|
|
7202
|
+
let requestHeaders = headers;
|
|
7203
|
+
let method = "POST";
|
|
7204
|
+
let requestBody = body;
|
|
7205
|
+
let redirectsFollowed = 0;
|
|
7206
|
+
for (;; ) {
|
|
7207
|
+
const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
|
|
7208
|
+
throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
|
|
7209
|
+
});
|
|
7210
|
+
const response = options.fetchImpl ? await options.fetchImpl(target, {
|
|
7211
|
+
method,
|
|
7212
|
+
headers: requestHeaders,
|
|
7213
|
+
body: requestBody,
|
|
7214
|
+
signal: controller.signal,
|
|
7215
|
+
redirect: "manual"
|
|
7216
|
+
}) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
|
|
7217
|
+
const location = response.headers.get("location");
|
|
7218
|
+
if (isRedirectStatus(response.status) && location) {
|
|
7219
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
7220
|
+
return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
|
|
7221
|
+
}
|
|
7222
|
+
redirectsFollowed += 1;
|
|
7223
|
+
const next = new URL(location, target);
|
|
7224
|
+
target = next;
|
|
7225
|
+
if (!redirectKeepsBody(response.status)) {
|
|
7226
|
+
method = "GET";
|
|
7227
|
+
requestBody = undefined;
|
|
7228
|
+
requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
|
|
7229
|
+
}
|
|
7230
|
+
continue;
|
|
7231
|
+
}
|
|
7232
|
+
const responseBody = truncate(await response.text());
|
|
7233
|
+
return {
|
|
7234
|
+
attempt: 1,
|
|
7235
|
+
status: response.ok ? "success" : "failed",
|
|
7236
|
+
startedAt,
|
|
7237
|
+
completedAt: now2(),
|
|
7238
|
+
responseStatus: response.status,
|
|
7239
|
+
responseBody,
|
|
7240
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
7241
|
+
};
|
|
7242
|
+
}
|
|
7243
|
+
} catch (error) {
|
|
7244
|
+
return {
|
|
7245
|
+
attempt: 1,
|
|
7246
|
+
status: "failed",
|
|
7247
|
+
startedAt,
|
|
7248
|
+
completedAt: now2(),
|
|
7249
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7250
|
+
};
|
|
7251
|
+
} finally {
|
|
7252
|
+
clearTimeout(timeout);
|
|
7253
|
+
}
|
|
7254
|
+
}
|
|
6726
7255
|
function failedAttempt(startedAt, error) {
|
|
6727
7256
|
return {
|
|
6728
7257
|
attempt: 1,
|
|
@@ -6737,7 +7266,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6737
7266
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
6738
7267
|
const startedAt = now2();
|
|
6739
7268
|
const eventJson = JSON.stringify(event);
|
|
6740
|
-
const
|
|
7269
|
+
const env2 = {
|
|
6741
7270
|
...process.env,
|
|
6742
7271
|
...channel.command.env,
|
|
6743
7272
|
HASNA_CHANNEL_ID: channel.id,
|
|
@@ -6751,10 +7280,10 @@ async function dispatchCommand(event, channel) {
|
|
|
6751
7280
|
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
6752
7281
|
HASNA_EVENT_JSON: eventJson
|
|
6753
7282
|
};
|
|
6754
|
-
return new Promise((
|
|
7283
|
+
return new Promise((resolve22) => {
|
|
6755
7284
|
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
6756
7285
|
cwd: channel.command.cwd,
|
|
6757
|
-
env,
|
|
7286
|
+
env: env2,
|
|
6758
7287
|
stdio: ["pipe", "pipe", "pipe"]
|
|
6759
7288
|
});
|
|
6760
7289
|
let stdout = "";
|
|
@@ -6769,7 +7298,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6769
7298
|
});
|
|
6770
7299
|
child.on("error", (error) => {
|
|
6771
7300
|
clearTimeout(timeout);
|
|
6772
|
-
|
|
7301
|
+
resolve22({
|
|
6773
7302
|
attempt: 1,
|
|
6774
7303
|
status: "failed",
|
|
6775
7304
|
startedAt,
|
|
@@ -6782,7 +7311,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6782
7311
|
child.on("close", (code, signal) => {
|
|
6783
7312
|
clearTimeout(timeout);
|
|
6784
7313
|
const success = code === 0;
|
|
6785
|
-
|
|
7314
|
+
resolve22({
|
|
6786
7315
|
attempt: 1,
|
|
6787
7316
|
status: success ? "success" : "failed",
|
|
6788
7317
|
startedAt,
|
|
@@ -6918,7 +7447,9 @@ class EventsClient {
|
|
|
6918
7447
|
this.transportOptions = {
|
|
6919
7448
|
fetchImpl: options.fetchImpl,
|
|
6920
7449
|
secretResolver: options.secretResolver,
|
|
6921
|
-
now: options.now
|
|
7450
|
+
now: options.now,
|
|
7451
|
+
tls: options.tls,
|
|
7452
|
+
webhookTargetPolicy: options.webhookTargetPolicy
|
|
6922
7453
|
};
|
|
6923
7454
|
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
6924
7455
|
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
@@ -7123,10 +7654,52 @@ function normalizeRetryPolicy(policy) {
|
|
|
7123
7654
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
7124
7655
|
};
|
|
7125
7656
|
}
|
|
7126
|
-
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS,
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7657
|
+
var KIND_ENV, APP_SLUG_RE, HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR", HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME", EVENTS_STORE_SENTINEL_FILE = "events.json", LOCAL_JSON_EVENT_CURSOR_PREFIX = "local-json-v1:", DEFAULT_EVENT_PAGE_LIMIT = 100, MAX_EVENT_PAGE_LIMIT = 1000, DEFAULT_SIGNATURE_TOLERANCE_MS, DEFAULT_MAX_REDIRECTS = 5, IPV4_PRIVATE_RANGES, IPV6_SPECIAL_PREFIXES, defaultTargetLookup = async (hostname) => {
|
|
7658
|
+
return dnsLookup(hostname, { all: true, verbatim: false });
|
|
7659
|
+
}, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
|
|
7660
|
+
var init_dist = __esm(() => {
|
|
7661
|
+
KIND_ENV = {
|
|
7662
|
+
config: "HASNA_CONFIG_HOME",
|
|
7663
|
+
data: "HASNA_DATA_HOME",
|
|
7664
|
+
state: "HASNA_STATE_HOME",
|
|
7665
|
+
cache: "HASNA_CACHE_HOME"
|
|
7666
|
+
};
|
|
7667
|
+
APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7668
|
+
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7669
|
+
IPV4_PRIVATE_RANGES = [
|
|
7670
|
+
[0, 16777215],
|
|
7671
|
+
[167772160, 184549375],
|
|
7672
|
+
[1681915904, 1686110207],
|
|
7673
|
+
[2130706432, 2147483647],
|
|
7674
|
+
[2851995648, 2852061183],
|
|
7675
|
+
[2886729728, 2887778303],
|
|
7676
|
+
[3221225472, 3221225727],
|
|
7677
|
+
[3221225984, 3221226239],
|
|
7678
|
+
[3227017984, 3227018239],
|
|
7679
|
+
[3232235520, 3232301055],
|
|
7680
|
+
[3323068416, 3323199487],
|
|
7681
|
+
[3325256704, 3325256959],
|
|
7682
|
+
[3405803776, 3405804031],
|
|
7683
|
+
[3758096384, 4294967295]
|
|
7684
|
+
];
|
|
7685
|
+
IPV6_SPECIAL_PREFIXES = [
|
|
7686
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
|
|
7687
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
7688
|
+
{ groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
|
|
7689
|
+
{ groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
|
|
7690
|
+
{ groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
|
|
7691
|
+
{ groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
7692
|
+
{ groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
|
|
7693
|
+
{ groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
|
|
7694
|
+
{ groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
7695
|
+
{ groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
|
|
7696
|
+
{ groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
|
|
7697
|
+
{ groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
|
|
7698
|
+
{ groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
7699
|
+
{ groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
7700
|
+
{ groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
|
|
7701
|
+
];
|
|
7702
|
+
EventValidationError = class EventValidationError extends Error {
|
|
7130
7703
|
eventType;
|
|
7131
7704
|
issues;
|
|
7132
7705
|
constructor(eventType, issues) {
|
|
@@ -7149,6 +7722,8 @@ function rowToTaskList(row) {
|
|
|
7149
7722
|
};
|
|
7150
7723
|
}
|
|
7151
7724
|
function createTaskList(input, db) {
|
|
7725
|
+
if (input.status !== undefined)
|
|
7726
|
+
throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
7152
7727
|
const d = db || getDatabase();
|
|
7153
7728
|
return d.transaction(() => {
|
|
7154
7729
|
const id = uuid();
|
|
@@ -7190,6 +7765,8 @@ function listTaskLists(projectId, db) {
|
|
|
7190
7765
|
return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
|
|
7191
7766
|
}
|
|
7192
7767
|
function updateTaskList(id, input, db) {
|
|
7768
|
+
if (input.status !== undefined)
|
|
7769
|
+
throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
7193
7770
|
const d = db || getDatabase();
|
|
7194
7771
|
return d.transaction(() => {
|
|
7195
7772
|
const existing = getTaskList(id, d);
|
|
@@ -7547,7 +8124,7 @@ function emitSharedTaskEventQuiet(input) {
|
|
|
7547
8124
|
}
|
|
7548
8125
|
var SOURCE = "todos";
|
|
7549
8126
|
var init_shared_events = __esm(() => {
|
|
7550
|
-
|
|
8127
|
+
init_dist();
|
|
7551
8128
|
init_database();
|
|
7552
8129
|
init_projects();
|
|
7553
8130
|
init_task_lists();
|
|
@@ -10132,7 +10709,9 @@ function listTasks(filter = {}, db) {
|
|
|
10132
10709
|
params.push(decoded.p, decoded.p, decoded.c, decoded.p, decoded.c, decoded.i);
|
|
10133
10710
|
} catch {}
|
|
10134
10711
|
}
|
|
10135
|
-
if (
|
|
10712
|
+
if (filter.archived_only) {
|
|
10713
|
+
conditions.push("archived_at IS NOT NULL");
|
|
10714
|
+
} else if (!filter.include_archived) {
|
|
10136
10715
|
conditions.push("archived_at IS NULL");
|
|
10137
10716
|
}
|
|
10138
10717
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
@@ -10145,7 +10724,8 @@ function listTasks(filter = {}, db) {
|
|
|
10145
10724
|
params.push(filter.offset);
|
|
10146
10725
|
}
|
|
10147
10726
|
}
|
|
10148
|
-
const
|
|
10727
|
+
const orderBy = filter.archived_only ? "archived_at DESC, id DESC" : `${PRIORITY_RANK}, created_at DESC, id ASC`;
|
|
10728
|
+
const rows = d.query(`SELECT * FROM tasks ${where} ORDER BY ${orderBy}${limitClause}`).all(...params);
|
|
10149
10729
|
return rows.map(rowToTask2);
|
|
10150
10730
|
}
|
|
10151
10731
|
function getTaskByFingerprint(fingerprint, db) {
|
|
@@ -10288,7 +10868,9 @@ function countTasks(filter = {}, db) {
|
|
|
10288
10868
|
}
|
|
10289
10869
|
}
|
|
10290
10870
|
addMetadataConditions(filter.metadata, conditions, params);
|
|
10291
|
-
if (
|
|
10871
|
+
if (filter.archived_only) {
|
|
10872
|
+
conditions.push("archived_at IS NOT NULL");
|
|
10873
|
+
} else if (!filter.include_archived) {
|
|
10292
10874
|
conditions.push("archived_at IS NULL");
|
|
10293
10875
|
}
|
|
10294
10876
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
@@ -10425,6 +11007,10 @@ function updateTaskStored(id, input, db) {
|
|
|
10425
11007
|
sets.push("completed_at = ?");
|
|
10426
11008
|
params.push(input.completed_at);
|
|
10427
11009
|
}
|
|
11010
|
+
if (input.archived_at !== undefined) {
|
|
11011
|
+
sets.push("archived_at = ?");
|
|
11012
|
+
params.push(input.archived_at);
|
|
11013
|
+
}
|
|
10428
11014
|
if (input.confidence !== undefined) {
|
|
10429
11015
|
sets.push("confidence = ?");
|
|
10430
11016
|
params.push(input.confidence);
|
|
@@ -10488,6 +11074,8 @@ function updateTaskStored(id, input, db) {
|
|
|
10488
11074
|
logTaskChange(id, "update", "parent_id", task.parent_id, input.parent_id, agentId, d);
|
|
10489
11075
|
if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
|
|
10490
11076
|
logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
|
|
11077
|
+
if (input.archived_at !== undefined && input.archived_at !== task.archived_at)
|
|
11078
|
+
logTaskChange(id, "update", "archived_at", task.archived_at, input.archived_at, agentId, d);
|
|
10491
11079
|
if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
|
|
10492
11080
|
logTaskChange(id, "update", "working_dir", task.working_dir, input.working_dir, agentId, d);
|
|
10493
11081
|
if (input.approved_by !== undefined)
|
|
@@ -11188,7 +11776,13 @@ function resolveCreateSlug(input, projectId, db) {
|
|
|
11188
11776
|
}
|
|
11189
11777
|
return nextPlanSlug(planSlugBase2(input.name), projectId, db);
|
|
11190
11778
|
}
|
|
11779
|
+
function assertLegacyPlanInput(input) {
|
|
11780
|
+
if (input.start_date !== undefined || input.end_date !== undefined || input.status === "planning" || input.status === "cancelled") {
|
|
11781
|
+
throw new Error("Plan calendar dates and planning/cancelled statuses require the shared Todos API; explicit SQLite storage cannot retain them");
|
|
11782
|
+
}
|
|
11783
|
+
}
|
|
11191
11784
|
function createPlan(input, db) {
|
|
11785
|
+
assertLegacyPlanInput(input);
|
|
11192
11786
|
const d = db || getDatabase();
|
|
11193
11787
|
const id = uuid();
|
|
11194
11788
|
const timestamp2 = now();
|
|
@@ -11269,6 +11863,7 @@ function updatePlanStored(id, input, db) {
|
|
|
11269
11863
|
return updated;
|
|
11270
11864
|
}
|
|
11271
11865
|
function updatePlan(id, input, db) {
|
|
11866
|
+
assertLegacyPlanInput(input);
|
|
11272
11867
|
const d = db || getDatabase();
|
|
11273
11868
|
return d.transaction(() => {
|
|
11274
11869
|
guardPlanRowsSqlite([id], d);
|
|
@@ -11743,27 +12338,27 @@ var init_boards = __esm(() => {
|
|
|
11743
12338
|
// src/lib/artifact-store.ts
|
|
11744
12339
|
import { createHash as createHash3 } from "crypto";
|
|
11745
12340
|
import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
11746
|
-
import { basename, dirname as dirname4, join as
|
|
12341
|
+
import { basename, dirname as dirname4, join as join6, resolve as resolve9 } from "path";
|
|
11747
12342
|
import { tmpdir as tmpdir2 } from "os";
|
|
11748
12343
|
function isInMemoryDb2(path) {
|
|
11749
12344
|
return path === ":memory:" || path.startsWith("file::memory:");
|
|
11750
12345
|
}
|
|
11751
12346
|
function artifactStoreRoot() {
|
|
11752
12347
|
if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
|
|
11753
|
-
return
|
|
12348
|
+
return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
|
|
11754
12349
|
if (process.env["TODOS_ARTIFACTS_DIR"])
|
|
11755
|
-
return
|
|
12350
|
+
return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
|
|
11756
12351
|
const dbPath = getDatabasePath();
|
|
11757
12352
|
if (isInMemoryDb2(dbPath))
|
|
11758
|
-
return
|
|
11759
|
-
return
|
|
12353
|
+
return join6(tmpdir2(), "hasna-todos-artifacts");
|
|
12354
|
+
return join6(dirname4(resolve9(dbPath)), "artifacts");
|
|
11760
12355
|
}
|
|
11761
12356
|
function artifactStorePath(relativePath) {
|
|
11762
12357
|
const normalized = relativePath.replace(/\\/g, "/");
|
|
11763
12358
|
if (normalized.includes("..") || normalized.startsWith("/") || normalized.length === 0) {
|
|
11764
12359
|
throw new Error("Invalid artifact store path");
|
|
11765
12360
|
}
|
|
11766
|
-
return
|
|
12361
|
+
return join6(artifactStoreRoot(), normalized);
|
|
11767
12362
|
}
|
|
11768
12363
|
function sha256(buffer) {
|
|
11769
12364
|
return createHash3("sha256").update(buffer).digest("hex");
|
|
@@ -11803,7 +12398,7 @@ function mediaTypeFor(path, textLike) {
|
|
|
11803
12398
|
return "application/octet-stream";
|
|
11804
12399
|
}
|
|
11805
12400
|
function storeArtifactContent(input) {
|
|
11806
|
-
const sourcePath =
|
|
12401
|
+
const sourcePath = resolve9(input.path);
|
|
11807
12402
|
if (!existsSync8(sourcePath))
|
|
11808
12403
|
return null;
|
|
11809
12404
|
const sourceStat = statSync2(sourcePath);
|
|
@@ -11821,7 +12416,7 @@ function storeArtifactContent(input) {
|
|
|
11821
12416
|
redactionStatus = "redacted";
|
|
11822
12417
|
}
|
|
11823
12418
|
const storedSha = sha256(storedBuffer);
|
|
11824
|
-
const relativePath =
|
|
12419
|
+
const relativePath = join6("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
|
|
11825
12420
|
const destination = artifactStorePath(relativePath);
|
|
11826
12421
|
if (!existsSync8(destination)) {
|
|
11827
12422
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
@@ -11958,19 +12553,19 @@ function importStoredArtifactContent(content) {
|
|
|
11958
12553
|
}
|
|
11959
12554
|
function getArtifactStoreRoot(dbPath) {
|
|
11960
12555
|
if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
|
|
11961
|
-
return
|
|
12556
|
+
return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
|
|
11962
12557
|
if (process.env["TODOS_ARTIFACTS_DIR"])
|
|
11963
|
-
return
|
|
12558
|
+
return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
|
|
11964
12559
|
const path = dbPath ?? getDatabasePath();
|
|
11965
12560
|
if (isInMemoryDb2(path))
|
|
11966
|
-
return
|
|
11967
|
-
return
|
|
12561
|
+
return join6(tmpdir2(), "hasna-todos-artifacts");
|
|
12562
|
+
return join6(dirname4(resolve9(path)), "artifacts");
|
|
11968
12563
|
}
|
|
11969
12564
|
function computeContentHash(path) {
|
|
11970
|
-
return sha256(readFileSync4(
|
|
12565
|
+
return sha256(readFileSync4(resolve9(path)));
|
|
11971
12566
|
}
|
|
11972
12567
|
function storeArtifactFile(input) {
|
|
11973
|
-
const sourcePath =
|
|
12568
|
+
const sourcePath = resolve9(input.sourcePath);
|
|
11974
12569
|
if (!existsSync8(sourcePath)) {
|
|
11975
12570
|
throw new Error(`Source file not found: ${input.sourcePath}`);
|
|
11976
12571
|
}
|
|
@@ -11984,7 +12579,7 @@ function storeArtifactFile(input) {
|
|
|
11984
12579
|
let localPath = sourcePath;
|
|
11985
12580
|
if (storageMode === "copy") {
|
|
11986
12581
|
const fileName = input.name && input.name.trim().length > 0 ? basename(input.name) : basename(sourcePath);
|
|
11987
|
-
const destination =
|
|
12582
|
+
const destination = join6(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
|
|
11988
12583
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
11989
12584
|
writeFileSync2(destination, buffer);
|
|
11990
12585
|
localPath = destination;
|
|
@@ -12019,7 +12614,7 @@ function buildArtifactExportManifest(artifacts, dbPath) {
|
|
|
12019
12614
|
};
|
|
12020
12615
|
}
|
|
12021
12616
|
function writeArtifactExportManifest(manifest, outputPath) {
|
|
12022
|
-
const destination =
|
|
12617
|
+
const destination = resolve9(outputPath);
|
|
12023
12618
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
12024
12619
|
writeFileSync2(destination, `${JSON.stringify(manifest, null, 2)}
|
|
12025
12620
|
`);
|
|
@@ -12707,7 +13302,9 @@ function finishTaskRunTransaction(input, db) {
|
|
|
12707
13302
|
const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
|
|
12708
13303
|
const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
|
|
12709
13304
|
if (!run) {
|
|
12710
|
-
|
|
13305
|
+
if (input.run_id)
|
|
13306
|
+
throw new InputValidationError(`Run not found: ${input.run_id}`, "Pass an existing run id, or --key.");
|
|
13307
|
+
throw new InputValidationError("runs finish requires a run id or --key", "Pass run_id, or key with task_id.");
|
|
12711
13308
|
}
|
|
12712
13309
|
if (input.task_id && run.task_id !== input.task_id) {
|
|
12713
13310
|
throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
|
|
@@ -13335,6 +13932,7 @@ function searchTasks(options, projectId, taskListId, db) {
|
|
|
13335
13932
|
|
|
13336
13933
|
// src/storage/local-sqlite.ts
|
|
13337
13934
|
init_tasks();
|
|
13935
|
+
init_task_graph();
|
|
13338
13936
|
init_projects();
|
|
13339
13937
|
init_plans();
|
|
13340
13938
|
|
|
@@ -13692,9 +14290,9 @@ function normalizeGeneratedAgentNames(db) {
|
|
|
13692
14290
|
|
|
13693
14291
|
// src/db/agents.ts
|
|
13694
14292
|
function getActiveWindowMs() {
|
|
13695
|
-
const
|
|
13696
|
-
if (
|
|
13697
|
-
const parsed = parseInt(
|
|
14293
|
+
const env2 = process.env["TODOS_AGENT_TIMEOUT_MS"];
|
|
14294
|
+
if (env2) {
|
|
14295
|
+
const parsed = parseInt(env2, 10);
|
|
13698
14296
|
if (!isNaN(parsed) && parsed > 0)
|
|
13699
14297
|
return parsed;
|
|
13700
14298
|
}
|
|
@@ -13898,6 +14496,19 @@ function listAgents(opts, db) {
|
|
|
13898
14496
|
}
|
|
13899
14497
|
return d.query("SELECT * FROM agents WHERE status = 'active' ORDER BY name").all().map(rowToAgent);
|
|
13900
14498
|
}
|
|
14499
|
+
function listAgentsPage(options, db) {
|
|
14500
|
+
if (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 500) {
|
|
14501
|
+
throw new Error("Agent page limit must be an integer from 1 to 500");
|
|
14502
|
+
}
|
|
14503
|
+
if (!Number.isSafeInteger(options.offset) || options.offset < 0) {
|
|
14504
|
+
throw new Error("Agent page offset must be a non-negative integer");
|
|
14505
|
+
}
|
|
14506
|
+
const d = db || getDatabase();
|
|
14507
|
+
const where = options.include_archived ? "" : "WHERE status = 'active'";
|
|
14508
|
+
const total = d.query(`SELECT COUNT(*) AS total FROM agents ${where}`).get().total;
|
|
14509
|
+
const agents = d.query(`SELECT * FROM agents ${where} ORDER BY LOWER(name), id LIMIT ? OFFSET ?`).all(options.limit, options.offset).map(rowToAgent);
|
|
14510
|
+
return { agents, total };
|
|
14511
|
+
}
|
|
13901
14512
|
function updateAgentActivity(id, db) {
|
|
13902
14513
|
const d = db || getDatabase();
|
|
13903
14514
|
d.run("UPDATE agents SET last_seen_at = ? WHERE id = ?", [now(), id]);
|
|
@@ -14540,6 +15151,171 @@ function rollbackPlanProjectLinkSqlite(input, db) {
|
|
|
14540
15151
|
})();
|
|
14541
15152
|
}
|
|
14542
15153
|
|
|
15154
|
+
// src/storage/machine-registry.ts
|
|
15155
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
15156
|
+
import { isDeepStrictEqual } from "util";
|
|
15157
|
+
class MachineRegistryError extends Error {
|
|
15158
|
+
status;
|
|
15159
|
+
constructor(message, status = 409) {
|
|
15160
|
+
super(message);
|
|
15161
|
+
this.status = status;
|
|
15162
|
+
}
|
|
15163
|
+
}
|
|
15164
|
+
var fields = new Set(["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"]);
|
|
15165
|
+
function validateMachines(value) {
|
|
15166
|
+
if (!Array.isArray(value) || value.length > 1e4)
|
|
15167
|
+
throw new MachineRegistryError("machines must be an array of at most 10000 complete records", 400);
|
|
15168
|
+
for (const row of value) {
|
|
15169
|
+
if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some((key) => !fields.has(key)))
|
|
15170
|
+
throw new MachineRegistryError("Invalid machine record fields", 400);
|
|
15171
|
+
for (const key of ["id", "name", "last_seen_at", "created_at"])
|
|
15172
|
+
if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 1024)
|
|
15173
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15174
|
+
for (const key of ["hostname", "platform", "ssh_address", "archived_at"])
|
|
15175
|
+
if (row[key] !== null && typeof row[key] !== "string")
|
|
15176
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15177
|
+
for (const key of ["last_seen_at", "created_at", "archived_at"])
|
|
15178
|
+
if (row[key] !== null && !Number.isFinite(Date.parse(row[key])))
|
|
15179
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15180
|
+
if (typeof row.is_primary !== "boolean" || !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
|
|
15181
|
+
throw new MachineRegistryError("Invalid machine flags or metadata", 400);
|
|
15182
|
+
if (!isDeepStrictEqual(JSON.parse(JSON.stringify(row.metadata)), row.metadata))
|
|
15183
|
+
throw new MachineRegistryError("Machine metadata must preserve JSON values exactly", 400);
|
|
15184
|
+
if (JSON.stringify(row.metadata).length > 262144)
|
|
15185
|
+
throw new MachineRegistryError("Machine metadata is too large", 400);
|
|
15186
|
+
if (row.is_primary && row.archived_at !== null)
|
|
15187
|
+
throw new MachineRegistryError("An archived machine cannot be primary", 400);
|
|
15188
|
+
}
|
|
15189
|
+
return value;
|
|
15190
|
+
}
|
|
15191
|
+
function planMachineImport(existing, incoming) {
|
|
15192
|
+
const rows = validateMachines(incoming);
|
|
15193
|
+
const byId = new Map(existing.map((row) => [row.id, row]));
|
|
15194
|
+
const byName = new Map(existing.map((row) => [row.name, row.id]));
|
|
15195
|
+
const insert = [];
|
|
15196
|
+
let skipped = 0;
|
|
15197
|
+
for (const row of rows) {
|
|
15198
|
+
const prior = byId.get(row.id);
|
|
15199
|
+
if (prior && !isDeepStrictEqual(prior, row))
|
|
15200
|
+
throw new MachineRegistryError("Machine migration conflicts with an existing identity; reconcile before retrying");
|
|
15201
|
+
if (byName.has(row.name) && byName.get(row.name) !== row.id)
|
|
15202
|
+
throw new MachineRegistryError("Machine name belongs to a different identity; migration was not applied");
|
|
15203
|
+
if (prior)
|
|
15204
|
+
skipped++;
|
|
15205
|
+
else {
|
|
15206
|
+
insert.push(row);
|
|
15207
|
+
byId.set(row.id, row);
|
|
15208
|
+
byName.set(row.name, row.id);
|
|
15209
|
+
}
|
|
15210
|
+
}
|
|
15211
|
+
if ([...byId.values()].filter((row) => row.is_primary).length > 1)
|
|
15212
|
+
throw new MachineRegistryError("Machine migration would create multiple primary machines");
|
|
15213
|
+
return { rows: insert, skipped };
|
|
15214
|
+
}
|
|
15215
|
+
function createPostgresMachineRegistry(client, service, table, ensure) {
|
|
15216
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
|
|
15217
|
+
throw new Error("Invalid machine registry table");
|
|
15218
|
+
async function list(connection = client) {
|
|
15219
|
+
await ensure();
|
|
15220
|
+
const result = await connection.query(`SELECT payload FROM ${table} WHERE service=$1 AND object_type='machines' AND deleted_at IS NULL ORDER BY object_id`, [service]);
|
|
15221
|
+
return validateMachines(result.rows.map((row) => typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload));
|
|
15222
|
+
}
|
|
15223
|
+
return { list, async execute(input) {
|
|
15224
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !["register", "heartbeat", "set-primary", "archive", "unarchive", "delete", "import"].includes(input.action))
|
|
15225
|
+
throw new MachineRegistryError("Invalid machine operation", 400);
|
|
15226
|
+
if (Object.keys(input).some((key) => !["action", "name", "id", "options", "machines"].includes(key)))
|
|
15227
|
+
throw new MachineRegistryError("Unknown machine operation fields", 400);
|
|
15228
|
+
if (input.options !== undefined && (!input.options || typeof input.options !== "object" || Array.isArray(input.options)))
|
|
15229
|
+
throw new MachineRegistryError("Invalid machine options", 400);
|
|
15230
|
+
await ensure();
|
|
15231
|
+
if (!client.transaction)
|
|
15232
|
+
throw new MachineRegistryError("Machine registry requires a transactional server backend", 501);
|
|
15233
|
+
return client.transaction(async (tx) => {
|
|
15234
|
+
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${service}:machine-registry`]);
|
|
15235
|
+
const current = await list(tx);
|
|
15236
|
+
let changed = [];
|
|
15237
|
+
let skipped = 0;
|
|
15238
|
+
let machine;
|
|
15239
|
+
let deleted;
|
|
15240
|
+
const stamp = new Date().toISOString();
|
|
15241
|
+
if (input.action === "import") {
|
|
15242
|
+
const plan = planMachineImport(current, input.machines);
|
|
15243
|
+
const ids = plan.rows.map((row) => row.id);
|
|
15244
|
+
if (ids.length) {
|
|
15245
|
+
const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id IN (SELECT jsonb_array_elements_text($2::text::jsonb)) AND deleted_at IS NOT NULL`, [service, JSON.stringify(ids)]);
|
|
15246
|
+
if (retired.rows.length)
|
|
15247
|
+
throw new MachineRegistryError("Machine migration contains a retired identity");
|
|
15248
|
+
}
|
|
15249
|
+
changed = plan.rows;
|
|
15250
|
+
skipped = plan.skipped;
|
|
15251
|
+
} else {
|
|
15252
|
+
if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 1024)
|
|
15253
|
+
throw new MachineRegistryError("Machine name or ID is required", 400);
|
|
15254
|
+
const matches = current.filter((row) => row.name === input.name || row.id === input.name);
|
|
15255
|
+
if (matches.length > 1)
|
|
15256
|
+
throw new MachineRegistryError("Machine selector is ambiguous between a name and another identity; use an unambiguous selector");
|
|
15257
|
+
const prior = matches[0];
|
|
15258
|
+
if (input.action === "register" || input.action === "heartbeat") {
|
|
15259
|
+
const opts = input.options ?? {};
|
|
15260
|
+
const allowed = new Set(["hostname", "platform", "ssh_address", "primary", "tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"]);
|
|
15261
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
15262
|
+
if (!allowed.has(key) || (key === "primary" ? typeof value !== "boolean" : typeof value !== "string" || value.length > 4096))
|
|
15263
|
+
throw new MachineRegistryError("Invalid machine registration options", 400);
|
|
15264
|
+
}
|
|
15265
|
+
if (input.id !== undefined && (typeof input.id !== "string" || !input.id.trim() || input.id.length > 1024))
|
|
15266
|
+
throw new MachineRegistryError("Invalid stable machine ID", 400);
|
|
15267
|
+
if (prior && input.id && input.id !== prior.id)
|
|
15268
|
+
throw new MachineRegistryError("Machine registration ID does not match existing identity");
|
|
15269
|
+
if (!prior && input.id && current.some((row) => row.id === input.id))
|
|
15270
|
+
throw new MachineRegistryError("Machine ID already belongs to another name");
|
|
15271
|
+
const metadata = { ...prior?.metadata };
|
|
15272
|
+
for (const key of ["tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"])
|
|
15273
|
+
if (opts[key] !== undefined)
|
|
15274
|
+
metadata[key] = opts[key];
|
|
15275
|
+
if (!prior && input.id) {
|
|
15276
|
+
const retired = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type='machines' AND object_id=$2 AND deleted_at IS NOT NULL`, [service, input.id]);
|
|
15277
|
+
if (retired.rows.length)
|
|
15278
|
+
throw new MachineRegistryError("Machine identity has been retired");
|
|
15279
|
+
}
|
|
15280
|
+
machine = { id: prior?.id ?? input.id ?? randomUUID3(), name: prior?.name ?? input.name, hostname: opts.hostname ?? prior?.hostname ?? null, platform: opts.platform ?? prior?.platform ?? null, ssh_address: opts.ssh_address ?? prior?.ssh_address ?? null, created_at: prior?.created_at ?? stamp, last_seen_at: stamp, archived_at: prior?.archived_at ?? null, is_primary: opts.primary === true || (prior?.is_primary ?? false), metadata };
|
|
15281
|
+
if (machine.archived_at && machine.is_primary)
|
|
15282
|
+
throw new MachineRegistryError("Cannot set archived machine as primary");
|
|
15283
|
+
changed = [machine];
|
|
15284
|
+
if (opts.primary === true)
|
|
15285
|
+
changed.push(...current.filter((row) => row.is_primary && row.id !== machine.id).map((row) => ({ ...row, is_primary: false })));
|
|
15286
|
+
} else {
|
|
15287
|
+
if (!prior)
|
|
15288
|
+
throw new MachineRegistryError("Machine not found", 404);
|
|
15289
|
+
machine = { ...prior };
|
|
15290
|
+
if (input.action === "set-primary") {
|
|
15291
|
+
if (prior.archived_at)
|
|
15292
|
+
throw new MachineRegistryError("Cannot set archived machine as primary");
|
|
15293
|
+
machine.is_primary = true;
|
|
15294
|
+
changed = current.filter((row) => row.is_primary && row.id !== prior.id).map((row) => ({ ...row, is_primary: false }));
|
|
15295
|
+
} else if (input.action === "archive" || input.action === "delete") {
|
|
15296
|
+
if (prior.is_primary)
|
|
15297
|
+
throw new MachineRegistryError("Cannot archive or delete the primary machine");
|
|
15298
|
+
const refs = await tx.query(`SELECT object_id FROM ${table} WHERE service=$1 AND object_type<>'machines' AND deleted_at IS NULL AND payload->>'machine_id'=$2 AND ($3='delete' OR (object_type='tasks' AND payload->>'status' IN ('pending','in_progress'))) LIMIT 1`, [service, prior.id, input.action]);
|
|
15299
|
+
if (refs.rows.length)
|
|
15300
|
+
throw new MachineRegistryError("Machine still has referenced records or active tasks");
|
|
15301
|
+
machine.archived_at = stamp;
|
|
15302
|
+
deleted = input.action === "delete" ? true : undefined;
|
|
15303
|
+
} else if (input.action === "unarchive")
|
|
15304
|
+
machine.archived_at = null;
|
|
15305
|
+
else
|
|
15306
|
+
throw new MachineRegistryError("Unknown machine action", 400);
|
|
15307
|
+
changed.push(machine);
|
|
15308
|
+
}
|
|
15309
|
+
}
|
|
15310
|
+
for (const row of changed) {
|
|
15311
|
+
await tx.query(`INSERT INTO ${table} (service,object_type,object_id,payload,updated_at,deleted_at,version) VALUES ($1,'machines',$2,$3::text::jsonb,$4,$5,1) ON CONFLICT (service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=COALESCE(${table}.version,0)+1`, [service, row.id, JSON.stringify(row), stamp, deleted && row.id === machine?.id ? stamp : null]);
|
|
15312
|
+
}
|
|
15313
|
+
const machines = await list(tx);
|
|
15314
|
+
return { schema_version: 2, machines, ...machine ? { machine } : {}, inserted: changed.filter((row) => !current.some((old) => old.id === row.id)).length, skipped, ...deleted ? { deleted } : {} };
|
|
15315
|
+
});
|
|
15316
|
+
} };
|
|
15317
|
+
}
|
|
15318
|
+
|
|
14543
15319
|
// src/storage/sqlite-snapshot.ts
|
|
14544
15320
|
init_database();
|
|
14545
15321
|
init_audit();
|
|
@@ -14753,6 +15529,7 @@ function exportSqliteTodosStorageSnapshot(db) {
|
|
|
14753
15529
|
return {
|
|
14754
15530
|
exportedAt: new Date().toISOString(),
|
|
14755
15531
|
source: "sqlite",
|
|
15532
|
+
machines: exportSqliteMachines(d),
|
|
14756
15533
|
tasks: listTasks({ include_archived: true }, d),
|
|
14757
15534
|
projects: listProjects(d),
|
|
14758
15535
|
projectMachinePaths: listProjectMachinePaths(d),
|
|
@@ -14766,7 +15543,6 @@ function exportSqliteTodosStorageSnapshot(db) {
|
|
|
14766
15543
|
};
|
|
14767
15544
|
}
|
|
14768
15545
|
function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
14769
|
-
const d = db ?? getDatabase();
|
|
14770
15546
|
const result = {
|
|
14771
15547
|
inserted: 0,
|
|
14772
15548
|
updated: 0,
|
|
@@ -14774,6 +15550,22 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14774
15550
|
skipped: 0,
|
|
14775
15551
|
errors: []
|
|
14776
15552
|
};
|
|
15553
|
+
if (snapshot.taskLists.some((row) => row.status !== undefined)) {
|
|
15554
|
+
result.errors.push("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
15555
|
+
return result;
|
|
15556
|
+
}
|
|
15557
|
+
const d = db ?? getDatabase();
|
|
15558
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
|
|
15559
|
+
result.errors.push("Machine tombstones require explicit registry lifecycle operations");
|
|
15560
|
+
return result;
|
|
15561
|
+
}
|
|
15562
|
+
try {
|
|
15563
|
+
if (snapshot.machines !== undefined)
|
|
15564
|
+
validateMachines(snapshot.machines);
|
|
15565
|
+
} catch (e) {
|
|
15566
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
15567
|
+
return result;
|
|
15568
|
+
}
|
|
14777
15569
|
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
14778
15570
|
if (result.errors.length === 0) {
|
|
14779
15571
|
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
@@ -14784,6 +15576,20 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14784
15576
|
result.errors.push(...auditImport.errors);
|
|
14785
15577
|
if (result.errors.length > 0)
|
|
14786
15578
|
return result;
|
|
15579
|
+
if (snapshot.machines?.length) {
|
|
15580
|
+
try {
|
|
15581
|
+
d.transaction(() => {
|
|
15582
|
+
const plan = planMachineImport(exportSqliteMachines(d), snapshot.machines);
|
|
15583
|
+
for (const row of plan.rows)
|
|
15584
|
+
d.run("INSERT INTO machines (id,name,hostname,platform,last_seen_at,metadata,created_at,ssh_address,is_primary,archived_at) VALUES (?,?,?,?,?,?,?,?,?,?)", [row.id, row.name, row.hostname, row.platform, row.last_seen_at, JSON.stringify(row.metadata), row.created_at, row.ssh_address, Number(row.is_primary), row.archived_at]);
|
|
15585
|
+
result.inserted += plan.rows.length;
|
|
15586
|
+
result.skipped += plan.skipped;
|
|
15587
|
+
})();
|
|
15588
|
+
} catch (error) {
|
|
15589
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
15590
|
+
return result;
|
|
15591
|
+
}
|
|
15592
|
+
}
|
|
14787
15593
|
result.skipped += auditImport.identicalReplayCount;
|
|
14788
15594
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
14789
15595
|
for (const row of rows) {
|
|
@@ -15018,8 +15824,111 @@ function clockColumnsForTable(table) {
|
|
|
15018
15824
|
return ["created_at"];
|
|
15019
15825
|
return ["updated_at", "created_at"];
|
|
15020
15826
|
}
|
|
15827
|
+
function exportSqliteMachines(db) {
|
|
15828
|
+
const columns = ["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"];
|
|
15829
|
+
const observed = db.query("PRAGMA table_info(machines)").all().map((row) => row.name);
|
|
15830
|
+
if (observed.length !== columns.length || observed.some((name) => !columns.includes(name)))
|
|
15831
|
+
throw new Error("Machine SQLite schema differs from the lossless migration contract; preserve the source and upgrade the migrator");
|
|
15832
|
+
return validateMachines(db.query("SELECT * FROM machines ORDER BY id").all().map((row) => {
|
|
15833
|
+
if (row.is_primary !== 0 && row.is_primary !== 1)
|
|
15834
|
+
throw new Error("Machine is_primary must be the raw SQLite integer 0 or 1; migration refused");
|
|
15835
|
+
return { ...row, is_primary: row.is_primary === 1, metadata: JSON.parse(String(row.metadata)) };
|
|
15836
|
+
}));
|
|
15837
|
+
}
|
|
15021
15838
|
|
|
15022
15839
|
// src/storage/local-sqlite.ts
|
|
15840
|
+
function bulkCreateAtomicSqlite(db, inputs) {
|
|
15841
|
+
return db.transaction(() => {
|
|
15842
|
+
const tempIds = new Map;
|
|
15843
|
+
const created = [];
|
|
15844
|
+
const dependencies = [];
|
|
15845
|
+
for (const input of inputs) {
|
|
15846
|
+
const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
|
|
15847
|
+
const task = createTask(taskInput, db);
|
|
15848
|
+
if (temp_id)
|
|
15849
|
+
tempIds.set(temp_id, task.id);
|
|
15850
|
+
created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
|
|
15851
|
+
}
|
|
15852
|
+
for (let index = 0;index < inputs.length; index++) {
|
|
15853
|
+
const input = inputs[index];
|
|
15854
|
+
const taskId = created[index].id;
|
|
15855
|
+
const seenDependencies = new Set;
|
|
15856
|
+
for (const reference of input.depends_on ?? []) {
|
|
15857
|
+
const dependencyId = tempIds.get(reference) ?? resolveTaskRefLocal(db, reference)?.id;
|
|
15858
|
+
if (!dependencyId)
|
|
15859
|
+
throw new TaskNotFoundError(reference);
|
|
15860
|
+
if (seenDependencies.has(dependencyId)) {
|
|
15861
|
+
throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
|
|
15862
|
+
}
|
|
15863
|
+
seenDependencies.add(dependencyId);
|
|
15864
|
+
addDependency(taskId, dependencyId, db);
|
|
15865
|
+
dependencies.push({ task_id: taskId, depends_on: dependencyId });
|
|
15866
|
+
}
|
|
15867
|
+
}
|
|
15868
|
+
return { schema_version: 1, atomic: true, created, dependencies };
|
|
15869
|
+
})();
|
|
15870
|
+
}
|
|
15871
|
+
function deleteTaskHierarchySqlite(db, rootId) {
|
|
15872
|
+
const rows = db.query(`WITH RECURSIVE tree(id, depth) AS (
|
|
15873
|
+
SELECT id, 0 FROM tasks WHERE id = ?
|
|
15874
|
+
UNION ALL
|
|
15875
|
+
SELECT child.id, tree.depth + 1 FROM tasks child JOIN tree ON child.parent_id = tree.id
|
|
15876
|
+
) SELECT id FROM tree ORDER BY depth DESC, id`).all(rootId);
|
|
15877
|
+
if (rows.length === 0)
|
|
15878
|
+
throw new TaskNotFoundError(rootId);
|
|
15879
|
+
for (const row of rows) {
|
|
15880
|
+
if (!deleteTask(row.id, db))
|
|
15881
|
+
throw new Error(`Atomic hierarchy delete lost task ${row.id}`);
|
|
15882
|
+
}
|
|
15883
|
+
}
|
|
15884
|
+
function bulkDeleteAtomicSqlite(db, ids, force) {
|
|
15885
|
+
return db.transaction(() => {
|
|
15886
|
+
const resolved = ids.map((reference) => ({ reference, task: resolveTaskRefLocal(db, reference) }));
|
|
15887
|
+
const seen = new Set;
|
|
15888
|
+
for (const item of resolved) {
|
|
15889
|
+
if (!item.task)
|
|
15890
|
+
continue;
|
|
15891
|
+
if (seen.has(item.task.id)) {
|
|
15892
|
+
throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
|
|
15893
|
+
}
|
|
15894
|
+
seen.add(item.task.id);
|
|
15895
|
+
}
|
|
15896
|
+
const childState = new Map;
|
|
15897
|
+
for (const item of resolved) {
|
|
15898
|
+
if (!item.task)
|
|
15899
|
+
continue;
|
|
15900
|
+
childState.set(item.task.id, Boolean(db.query("SELECT id FROM tasks WHERE parent_id = ? LIMIT 1").get(item.task.id)));
|
|
15901
|
+
}
|
|
15902
|
+
const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
|
|
15903
|
+
const plannedIds = new Set(planned.map((item) => item.task.id));
|
|
15904
|
+
const roots = planned.filter((item) => {
|
|
15905
|
+
if (!force)
|
|
15906
|
+
return true;
|
|
15907
|
+
let parentId = item.task.parent_id;
|
|
15908
|
+
while (parentId) {
|
|
15909
|
+
if (plannedIds.has(parentId))
|
|
15910
|
+
return false;
|
|
15911
|
+
parentId = getTask(parentId, db)?.parent_id ?? null;
|
|
15912
|
+
}
|
|
15913
|
+
return true;
|
|
15914
|
+
});
|
|
15915
|
+
for (const item of roots)
|
|
15916
|
+
deleteTaskHierarchySqlite(db, item.task.id);
|
|
15917
|
+
return {
|
|
15918
|
+
schema_version: 1,
|
|
15919
|
+
atomic: true,
|
|
15920
|
+
force,
|
|
15921
|
+
results: resolved.map((item) => {
|
|
15922
|
+
if (!item.task)
|
|
15923
|
+
return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
|
|
15924
|
+
if (!force && childState.get(item.task.id)) {
|
|
15925
|
+
return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
|
|
15926
|
+
}
|
|
15927
|
+
return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
|
|
15928
|
+
})
|
|
15929
|
+
};
|
|
15930
|
+
})();
|
|
15931
|
+
}
|
|
15023
15932
|
var TASK_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15024
15933
|
function resolveTaskRefLocal(db, ref) {
|
|
15025
15934
|
const raw = ref.trim().toLowerCase();
|
|
@@ -15116,6 +16025,8 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15116
16025
|
},
|
|
15117
16026
|
handoffStaleLock: (input) => handoffStaleTaskLock(input, database()),
|
|
15118
16027
|
delete: (id) => deleteTask(id, database()),
|
|
16028
|
+
bulkCreateAtomic: (inputs, _context) => bulkCreateAtomicSqlite(database(), inputs),
|
|
16029
|
+
bulkDeleteAtomic: (ids, force, _context) => bulkDeleteAtomicSqlite(database(), ids, force),
|
|
15119
16030
|
start: (id, agentId) => startTask(id, agentId, database()),
|
|
15120
16031
|
complete: (id, agentId, options2) => completeTask(id, agentId, database(), options2),
|
|
15121
16032
|
fail: (id, agentId, reason, options2) => failTask(id, agentId, reason, options2, database()),
|
|
@@ -15124,6 +16035,30 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15124
16035
|
getActiveWork: (filters) => getActiveWork(filters, database()),
|
|
15125
16036
|
getChangedSince: (since, filters) => getTasksChangedSince(since, filters, database())
|
|
15126
16037
|
},
|
|
16038
|
+
dependencies: {
|
|
16039
|
+
add: (taskId, dependsOn) => {
|
|
16040
|
+
addDependency(taskId, dependsOn, database());
|
|
16041
|
+
return { task_id: taskId, depends_on: dependsOn };
|
|
16042
|
+
},
|
|
16043
|
+
remove: (taskId, dependsOn) => removeDependency(taskId, dependsOn, database()),
|
|
16044
|
+
list: (taskId) => {
|
|
16045
|
+
const dependencies = getTaskDependencies(taskId, database());
|
|
16046
|
+
const blocks = getTaskDependents(taskId, database());
|
|
16047
|
+
return { dependencies, blocks, blocked_by: blocks };
|
|
16048
|
+
},
|
|
16049
|
+
listPage: ({ limit, offset }) => {
|
|
16050
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
|
16051
|
+
throw new Error("SQLite dependency page limit must be an integer from 1 to 500");
|
|
16052
|
+
}
|
|
16053
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
16054
|
+
throw new Error("SQLite dependency page offset must be a non-negative integer");
|
|
16055
|
+
}
|
|
16056
|
+
const totalRow = database().query("SELECT COUNT(*) AS total FROM task_dependencies").get();
|
|
16057
|
+
const dependencies = database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on LIMIT ? OFFSET ?").all(limit, offset);
|
|
16058
|
+
return { dependencies, total: totalRow.total };
|
|
16059
|
+
},
|
|
16060
|
+
listAll: () => database().query("SELECT task_id, depends_on FROM task_dependencies ORDER BY task_id, depends_on").all()
|
|
16061
|
+
},
|
|
15127
16062
|
projects: {
|
|
15128
16063
|
create: (input) => createProject(input, database()),
|
|
15129
16064
|
get: (id) => getProject(id, database()),
|
|
@@ -15167,6 +16102,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15167
16102
|
get: (id) => getAgent(id, database()),
|
|
15168
16103
|
getByName: (name) => getAgentByName(name, database()),
|
|
15169
16104
|
list: (options2) => listAgents(options2, database()),
|
|
16105
|
+
listPage: (options2) => listAgentsPage(options2, database()),
|
|
15170
16106
|
update: (id, input) => updateAgent(id, input, database())
|
|
15171
16107
|
},
|
|
15172
16108
|
taskLists: {
|
|
@@ -15205,6 +16141,29 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15205
16141
|
return comments;
|
|
15206
16142
|
},
|
|
15207
16143
|
getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
|
|
16144
|
+
getTaskHistoryPage: (taskId, options2) => {
|
|
16145
|
+
if (!Number.isSafeInteger(options2.limit) || options2.limit < 1 || options2.limit > 500) {
|
|
16146
|
+
throw new Error("Task history limit must be an integer from 1 to 500");
|
|
16147
|
+
}
|
|
16148
|
+
if (!Number.isSafeInteger(options2.offset) || options2.offset < 0) {
|
|
16149
|
+
throw new Error("Task history offset must be a non-negative integer");
|
|
16150
|
+
}
|
|
16151
|
+
const conditions = ["task_id = ?"];
|
|
16152
|
+
const values = [taskId];
|
|
16153
|
+
if (options2.since) {
|
|
16154
|
+
conditions.push("created_at >= ?");
|
|
16155
|
+
values.push(options2.since);
|
|
16156
|
+
}
|
|
16157
|
+
if (options2.until) {
|
|
16158
|
+
conditions.push("created_at <= ?");
|
|
16159
|
+
values.push(options2.until);
|
|
16160
|
+
}
|
|
16161
|
+
const where = conditions.join(" AND ");
|
|
16162
|
+
const total = database().query(`SELECT COUNT(*) AS total FROM task_history WHERE ${where}`).get(...values).total;
|
|
16163
|
+
const direction = options2.order === "asc" ? "ASC" : "DESC";
|
|
16164
|
+
const history = database().query(`SELECT * FROM task_history WHERE ${where} ORDER BY created_at ${direction}, id ${direction} LIMIT ? OFFSET ?`).all(...values, options2.limit, options2.offset);
|
|
16165
|
+
return { history, total };
|
|
16166
|
+
},
|
|
15208
16167
|
getRecentActivity: (limit) => getRecentActivity(limit, database())
|
|
15209
16168
|
},
|
|
15210
16169
|
sync: {
|
|
@@ -15223,19 +16182,1168 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15223
16182
|
return adapter;
|
|
15224
16183
|
}
|
|
15225
16184
|
|
|
15226
|
-
// src/
|
|
16185
|
+
// src/lib/builtin-template-library.ts
|
|
16186
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
16187
|
+
import { join as join7 } from "path";
|
|
16188
|
+
var BUILTIN_TEMPLATE_LIBRARY_VERSION = "2026-05-21";
|
|
16189
|
+
var BUILTIN_TEMPLATE_LIBRARY_SOURCE = "bundled-local-template-library";
|
|
16190
|
+
var TEMPLATE_LIBRARY_SCHEMA = "todos.template_library.v1";
|
|
16191
|
+
var BUILTIN_TEMPLATES = [
|
|
16192
|
+
{
|
|
16193
|
+
name: "bug-fix",
|
|
16194
|
+
description: "Reproduce, diagnose, fix, test, and release a defect.",
|
|
16195
|
+
category: "bug-fix",
|
|
16196
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16197
|
+
variables: [
|
|
16198
|
+
{ name: "bug", required: true, description: "Bug description" }
|
|
16199
|
+
],
|
|
16200
|
+
tasks: [
|
|
16201
|
+
{
|
|
16202
|
+
position: 0,
|
|
16203
|
+
title_pattern: "Reproduce: {bug}",
|
|
16204
|
+
priority: "critical",
|
|
16205
|
+
tags: ["bug", "repro"]
|
|
16206
|
+
},
|
|
16207
|
+
{
|
|
16208
|
+
position: 1,
|
|
16209
|
+
title_pattern: "Diagnose root cause of {bug}",
|
|
16210
|
+
priority: "critical",
|
|
16211
|
+
tags: ["bug", "diagnosis"],
|
|
16212
|
+
depends_on_positions: [0]
|
|
16213
|
+
},
|
|
16214
|
+
{
|
|
16215
|
+
position: 2,
|
|
16216
|
+
title_pattern: "Write regression test for {bug}",
|
|
16217
|
+
priority: "high",
|
|
16218
|
+
tags: ["bug", "test"],
|
|
16219
|
+
depends_on_positions: [1]
|
|
16220
|
+
},
|
|
16221
|
+
{
|
|
16222
|
+
position: 3,
|
|
16223
|
+
title_pattern: "Implement fix for {bug}",
|
|
16224
|
+
priority: "critical",
|
|
16225
|
+
tags: ["bug", "implementation"],
|
|
16226
|
+
depends_on_positions: [2]
|
|
16227
|
+
},
|
|
16228
|
+
{
|
|
16229
|
+
position: 4,
|
|
16230
|
+
title_pattern: "Run full verification for {bug}",
|
|
16231
|
+
priority: "high",
|
|
16232
|
+
tags: ["bug", "verification"],
|
|
16233
|
+
depends_on_positions: [3]
|
|
16234
|
+
},
|
|
16235
|
+
{
|
|
16236
|
+
position: 5,
|
|
16237
|
+
title_pattern: "Publish and smoke test fix for {bug}",
|
|
16238
|
+
priority: "high",
|
|
16239
|
+
tags: ["bug", "release"],
|
|
16240
|
+
depends_on_positions: [4]
|
|
16241
|
+
}
|
|
16242
|
+
]
|
|
16243
|
+
},
|
|
16244
|
+
{
|
|
16245
|
+
name: "feature-implementation",
|
|
16246
|
+
description: "Plan, build, test, document, and release a product feature.",
|
|
16247
|
+
category: "feature",
|
|
16248
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16249
|
+
variables: [
|
|
16250
|
+
{ name: "feature", required: true, description: "Feature name" },
|
|
16251
|
+
{
|
|
16252
|
+
name: "scope",
|
|
16253
|
+
required: false,
|
|
16254
|
+
default: "medium",
|
|
16255
|
+
description: "Implementation size or risk"
|
|
16256
|
+
}
|
|
16257
|
+
],
|
|
16258
|
+
tasks: [
|
|
16259
|
+
{
|
|
16260
|
+
position: 0,
|
|
16261
|
+
title_pattern: "Define acceptance criteria for {feature}",
|
|
16262
|
+
priority: "high",
|
|
16263
|
+
tags: ["feature", "spec"]
|
|
16264
|
+
},
|
|
16265
|
+
{
|
|
16266
|
+
position: 1,
|
|
16267
|
+
title_pattern: "Design {scope} implementation plan for {feature}",
|
|
16268
|
+
priority: "high",
|
|
16269
|
+
tags: ["feature", "design"],
|
|
16270
|
+
depends_on_positions: [0]
|
|
16271
|
+
},
|
|
16272
|
+
{
|
|
16273
|
+
position: 2,
|
|
16274
|
+
title_pattern: "Implement {feature}",
|
|
16275
|
+
priority: "critical",
|
|
16276
|
+
tags: ["feature", "implementation"],
|
|
16277
|
+
depends_on_positions: [1]
|
|
16278
|
+
},
|
|
16279
|
+
{
|
|
16280
|
+
position: 3,
|
|
16281
|
+
title_pattern: "Add tests for {feature}",
|
|
16282
|
+
priority: "high",
|
|
16283
|
+
tags: ["feature", "test"],
|
|
16284
|
+
depends_on_positions: [2]
|
|
16285
|
+
},
|
|
16286
|
+
{
|
|
16287
|
+
position: 4,
|
|
16288
|
+
title_pattern: "Update docs for {feature}",
|
|
16289
|
+
priority: "medium",
|
|
16290
|
+
tags: ["feature", "docs"],
|
|
16291
|
+
depends_on_positions: [2]
|
|
16292
|
+
},
|
|
16293
|
+
{
|
|
16294
|
+
position: 5,
|
|
16295
|
+
title_pattern: "Run release checks for {feature}",
|
|
16296
|
+
priority: "high",
|
|
16297
|
+
tags: ["feature", "verification"],
|
|
16298
|
+
depends_on_positions: [3, 4]
|
|
16299
|
+
}
|
|
16300
|
+
]
|
|
16301
|
+
},
|
|
16302
|
+
{
|
|
16303
|
+
name: "security-review",
|
|
16304
|
+
description: "Threat model, test, remediate, and report on security posture.",
|
|
16305
|
+
category: "security",
|
|
16306
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16307
|
+
variables: [
|
|
16308
|
+
{
|
|
16309
|
+
name: "target",
|
|
16310
|
+
required: true,
|
|
16311
|
+
description: "System, package, or change under review"
|
|
16312
|
+
}
|
|
16313
|
+
],
|
|
16314
|
+
tasks: [
|
|
16315
|
+
{
|
|
16316
|
+
position: 0,
|
|
16317
|
+
title_pattern: "Map trust boundaries for {target}",
|
|
16318
|
+
priority: "critical",
|
|
16319
|
+
tags: ["security", "threat-model"]
|
|
16320
|
+
},
|
|
16321
|
+
{
|
|
16322
|
+
position: 1,
|
|
16323
|
+
title_pattern: "Scan {target} for vulnerabilities and secret exposure",
|
|
16324
|
+
priority: "critical",
|
|
16325
|
+
tags: ["security", "scan"],
|
|
16326
|
+
depends_on_positions: [0]
|
|
16327
|
+
},
|
|
16328
|
+
{
|
|
16329
|
+
position: 2,
|
|
16330
|
+
title_pattern: "Review authz, data access, and dependency risks in {target}",
|
|
16331
|
+
priority: "critical",
|
|
16332
|
+
tags: ["security", "review"],
|
|
16333
|
+
depends_on_positions: [0]
|
|
16334
|
+
},
|
|
16335
|
+
{
|
|
16336
|
+
position: 3,
|
|
16337
|
+
title_pattern: "Fix critical security findings in {target}",
|
|
16338
|
+
priority: "critical",
|
|
16339
|
+
tags: ["security", "fix"],
|
|
16340
|
+
depends_on_positions: [1, 2]
|
|
16341
|
+
},
|
|
16342
|
+
{
|
|
16343
|
+
position: 4,
|
|
16344
|
+
title_pattern: "Retest {target} security fixes",
|
|
16345
|
+
priority: "high",
|
|
16346
|
+
tags: ["security", "verification"],
|
|
16347
|
+
depends_on_positions: [3]
|
|
16348
|
+
},
|
|
16349
|
+
{
|
|
16350
|
+
position: 5,
|
|
16351
|
+
title_pattern: "Write local security review report for {target}",
|
|
16352
|
+
priority: "medium",
|
|
16353
|
+
tags: ["security", "report"],
|
|
16354
|
+
depends_on_positions: [4]
|
|
16355
|
+
}
|
|
16356
|
+
]
|
|
16357
|
+
},
|
|
16358
|
+
{
|
|
16359
|
+
name: "release",
|
|
16360
|
+
description: "Prepare, verify, publish, install, and smoke test a package release.",
|
|
16361
|
+
category: "release",
|
|
16362
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16363
|
+
variables: [
|
|
16364
|
+
{ name: "package", required: true, description: "Package name" },
|
|
16365
|
+
{
|
|
16366
|
+
name: "version",
|
|
16367
|
+
required: false,
|
|
16368
|
+
default: "patch",
|
|
16369
|
+
description: "Release version or bump type"
|
|
16370
|
+
}
|
|
16371
|
+
],
|
|
16372
|
+
tasks: [
|
|
16373
|
+
{
|
|
16374
|
+
position: 0,
|
|
16375
|
+
title_pattern: "Prepare {package} {version} release notes",
|
|
16376
|
+
priority: "medium",
|
|
16377
|
+
tags: ["release", "docs"]
|
|
16378
|
+
},
|
|
16379
|
+
{
|
|
16380
|
+
position: 1,
|
|
16381
|
+
title_pattern: "Run full tests for {package}",
|
|
16382
|
+
priority: "critical",
|
|
16383
|
+
tags: ["release", "test"]
|
|
16384
|
+
},
|
|
16385
|
+
{
|
|
16386
|
+
position: 2,
|
|
16387
|
+
title_pattern: "Run build and release verification for {package}",
|
|
16388
|
+
priority: "critical",
|
|
16389
|
+
tags: ["release", "verification"],
|
|
16390
|
+
depends_on_positions: [1]
|
|
16391
|
+
},
|
|
16392
|
+
{
|
|
16393
|
+
position: 3,
|
|
16394
|
+
title_pattern: "Scan {package} release diff for secrets",
|
|
16395
|
+
priority: "critical",
|
|
16396
|
+
tags: ["release", "security"],
|
|
16397
|
+
depends_on_positions: [2]
|
|
16398
|
+
},
|
|
16399
|
+
{
|
|
16400
|
+
position: 4,
|
|
16401
|
+
title_pattern: "Publish {package} {version}",
|
|
16402
|
+
priority: "high",
|
|
16403
|
+
tags: ["release", "publish"],
|
|
16404
|
+
depends_on_positions: [3]
|
|
16405
|
+
},
|
|
16406
|
+
{
|
|
16407
|
+
position: 5,
|
|
16408
|
+
title_pattern: "Install and smoke test published {package}",
|
|
16409
|
+
priority: "high",
|
|
16410
|
+
tags: ["release", "smoke"],
|
|
16411
|
+
depends_on_positions: [4]
|
|
16412
|
+
}
|
|
16413
|
+
]
|
|
16414
|
+
},
|
|
16415
|
+
{
|
|
16416
|
+
name: "migration",
|
|
16417
|
+
description: "Plan, test, apply, and verify a schema or data migration.",
|
|
16418
|
+
category: "migration",
|
|
16419
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16420
|
+
variables: [
|
|
16421
|
+
{
|
|
16422
|
+
name: "migration",
|
|
16423
|
+
required: true,
|
|
16424
|
+
description: "Migration name or objective"
|
|
16425
|
+
}
|
|
16426
|
+
],
|
|
16427
|
+
tasks: [
|
|
16428
|
+
{
|
|
16429
|
+
position: 0,
|
|
16430
|
+
title_pattern: "Design migration plan for {migration}",
|
|
16431
|
+
priority: "critical",
|
|
16432
|
+
tags: ["migration", "plan"]
|
|
16433
|
+
},
|
|
16434
|
+
{
|
|
16435
|
+
position: 1,
|
|
16436
|
+
title_pattern: "Write rollback plan for {migration}",
|
|
16437
|
+
priority: "critical",
|
|
16438
|
+
tags: ["migration", "rollback"],
|
|
16439
|
+
depends_on_positions: [0]
|
|
16440
|
+
},
|
|
16441
|
+
{
|
|
16442
|
+
position: 2,
|
|
16443
|
+
title_pattern: "Implement migration {migration}",
|
|
16444
|
+
priority: "critical",
|
|
16445
|
+
tags: ["migration", "implementation"],
|
|
16446
|
+
depends_on_positions: [0]
|
|
16447
|
+
},
|
|
16448
|
+
{
|
|
16449
|
+
position: 3,
|
|
16450
|
+
title_pattern: "Test migration {migration} on fixture data",
|
|
16451
|
+
priority: "high",
|
|
16452
|
+
tags: ["migration", "test"],
|
|
16453
|
+
depends_on_positions: [2]
|
|
16454
|
+
},
|
|
16455
|
+
{
|
|
16456
|
+
position: 4,
|
|
16457
|
+
title_pattern: "Run migration {migration} verification and drift checks",
|
|
16458
|
+
priority: "high",
|
|
16459
|
+
tags: ["migration", "verification"],
|
|
16460
|
+
depends_on_positions: [1, 3]
|
|
16461
|
+
},
|
|
16462
|
+
{
|
|
16463
|
+
position: 5,
|
|
16464
|
+
title_pattern: "Document migration {migration} evidence",
|
|
16465
|
+
priority: "medium",
|
|
16466
|
+
tags: ["migration", "docs"],
|
|
16467
|
+
depends_on_positions: [4]
|
|
16468
|
+
}
|
|
16469
|
+
]
|
|
16470
|
+
},
|
|
16471
|
+
{
|
|
16472
|
+
name: "incident",
|
|
16473
|
+
description: "Triage, mitigate, repair, verify, and retrospect an incident.",
|
|
16474
|
+
category: "incident",
|
|
16475
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16476
|
+
variables: [
|
|
16477
|
+
{ name: "incident", required: true, description: "Incident summary" }
|
|
16478
|
+
],
|
|
16479
|
+
tasks: [
|
|
16480
|
+
{
|
|
16481
|
+
position: 0,
|
|
16482
|
+
title_pattern: "Triage incident: {incident}",
|
|
16483
|
+
priority: "critical",
|
|
16484
|
+
tags: ["incident", "triage"]
|
|
16485
|
+
},
|
|
16486
|
+
{
|
|
16487
|
+
position: 1,
|
|
16488
|
+
title_pattern: "Mitigate customer impact for {incident}",
|
|
16489
|
+
priority: "critical",
|
|
16490
|
+
tags: ["incident", "mitigation"],
|
|
16491
|
+
depends_on_positions: [0]
|
|
16492
|
+
},
|
|
16493
|
+
{
|
|
16494
|
+
position: 2,
|
|
16495
|
+
title_pattern: "Diagnose root cause for {incident}",
|
|
16496
|
+
priority: "critical",
|
|
16497
|
+
tags: ["incident", "diagnosis"],
|
|
16498
|
+
depends_on_positions: [0]
|
|
16499
|
+
},
|
|
16500
|
+
{
|
|
16501
|
+
position: 3,
|
|
16502
|
+
title_pattern: "Implement durable repair for {incident}",
|
|
16503
|
+
priority: "critical",
|
|
16504
|
+
tags: ["incident", "repair"],
|
|
16505
|
+
depends_on_positions: [2]
|
|
16506
|
+
},
|
|
16507
|
+
{
|
|
16508
|
+
position: 4,
|
|
16509
|
+
title_pattern: "Verify recovery from {incident}",
|
|
16510
|
+
priority: "high",
|
|
16511
|
+
tags: ["incident", "verification"],
|
|
16512
|
+
depends_on_positions: [1, 3]
|
|
16513
|
+
},
|
|
16514
|
+
{
|
|
16515
|
+
position: 5,
|
|
16516
|
+
title_pattern: "Write retrospective for {incident}",
|
|
16517
|
+
priority: "medium",
|
|
16518
|
+
tags: ["incident", "retro"],
|
|
16519
|
+
depends_on_positions: [4]
|
|
16520
|
+
}
|
|
16521
|
+
]
|
|
16522
|
+
},
|
|
16523
|
+
{
|
|
16524
|
+
name: "docs-refresh",
|
|
16525
|
+
description: "Audit, update, validate, and publish documentation changes.",
|
|
16526
|
+
category: "docs",
|
|
16527
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16528
|
+
variables: [
|
|
16529
|
+
{
|
|
16530
|
+
name: "area",
|
|
16531
|
+
required: true,
|
|
16532
|
+
description: "Documentation area or product surface"
|
|
16533
|
+
}
|
|
16534
|
+
],
|
|
16535
|
+
tasks: [
|
|
16536
|
+
{
|
|
16537
|
+
position: 0,
|
|
16538
|
+
title_pattern: "Audit current docs for {area}",
|
|
16539
|
+
priority: "medium",
|
|
16540
|
+
tags: ["docs", "audit"]
|
|
16541
|
+
},
|
|
16542
|
+
{
|
|
16543
|
+
position: 1,
|
|
16544
|
+
title_pattern: "Update examples and commands for {area}",
|
|
16545
|
+
priority: "medium",
|
|
16546
|
+
tags: ["docs", "examples"],
|
|
16547
|
+
depends_on_positions: [0]
|
|
16548
|
+
},
|
|
16549
|
+
{
|
|
16550
|
+
position: 2,
|
|
16551
|
+
title_pattern: "Validate docs snippets for {area}",
|
|
16552
|
+
priority: "high",
|
|
16553
|
+
tags: ["docs", "verification"],
|
|
16554
|
+
depends_on_positions: [1]
|
|
16555
|
+
},
|
|
16556
|
+
{
|
|
16557
|
+
position: 3,
|
|
16558
|
+
title_pattern: "Refresh screenshots or generated artifacts for {area}",
|
|
16559
|
+
priority: "medium",
|
|
16560
|
+
tags: ["docs", "assets"],
|
|
16561
|
+
depends_on_positions: [1]
|
|
16562
|
+
},
|
|
16563
|
+
{
|
|
16564
|
+
position: 4,
|
|
16565
|
+
title_pattern: "Publish docs refresh for {area}",
|
|
16566
|
+
priority: "medium",
|
|
16567
|
+
tags: ["docs", "release"],
|
|
16568
|
+
depends_on_positions: [2, 3]
|
|
16569
|
+
}
|
|
16570
|
+
]
|
|
16571
|
+
},
|
|
16572
|
+
{
|
|
16573
|
+
name: "qa",
|
|
16574
|
+
description: "Build a focused QA plan, execute checks, file defects, and sign off.",
|
|
16575
|
+
category: "qa",
|
|
16576
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16577
|
+
variables: [
|
|
16578
|
+
{
|
|
16579
|
+
name: "target",
|
|
16580
|
+
required: true,
|
|
16581
|
+
description: "Feature, release, or workflow under QA"
|
|
16582
|
+
}
|
|
16583
|
+
],
|
|
16584
|
+
tasks: [
|
|
16585
|
+
{
|
|
16586
|
+
position: 0,
|
|
16587
|
+
title_pattern: "Create QA matrix for {target}",
|
|
16588
|
+
priority: "high",
|
|
16589
|
+
tags: ["qa", "plan"]
|
|
16590
|
+
},
|
|
16591
|
+
{
|
|
16592
|
+
position: 1,
|
|
16593
|
+
title_pattern: "Run happy-path QA for {target}",
|
|
16594
|
+
priority: "high",
|
|
16595
|
+
tags: ["qa", "manual"],
|
|
16596
|
+
depends_on_positions: [0]
|
|
16597
|
+
},
|
|
16598
|
+
{
|
|
16599
|
+
position: 2,
|
|
16600
|
+
title_pattern: "Run edge-case QA for {target}",
|
|
16601
|
+
priority: "high",
|
|
16602
|
+
tags: ["qa", "edge-cases"],
|
|
16603
|
+
depends_on_positions: [0]
|
|
16604
|
+
},
|
|
16605
|
+
{
|
|
16606
|
+
position: 3,
|
|
16607
|
+
title_pattern: "File and dedupe QA defects for {target}",
|
|
16608
|
+
priority: "medium",
|
|
16609
|
+
tags: ["qa", "bugs"],
|
|
16610
|
+
depends_on_positions: [1, 2]
|
|
16611
|
+
},
|
|
16612
|
+
{
|
|
16613
|
+
position: 4,
|
|
16614
|
+
title_pattern: "Verify QA fixes for {target}",
|
|
16615
|
+
priority: "high",
|
|
16616
|
+
tags: ["qa", "verification"],
|
|
16617
|
+
depends_on_positions: [3]
|
|
16618
|
+
},
|
|
16619
|
+
{
|
|
16620
|
+
position: 5,
|
|
16621
|
+
title_pattern: "Record QA signoff for {target}",
|
|
16622
|
+
priority: "medium",
|
|
16623
|
+
tags: ["qa", "signoff"],
|
|
16624
|
+
depends_on_positions: [4]
|
|
16625
|
+
}
|
|
16626
|
+
]
|
|
16627
|
+
},
|
|
16628
|
+
{
|
|
16629
|
+
name: "open-source-project",
|
|
16630
|
+
description: "Full open-source project bootstrap \u2014 scaffold to publish",
|
|
16631
|
+
category: "open-source",
|
|
16632
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16633
|
+
variables: [
|
|
16634
|
+
{ name: "name", required: true, description: "Service name" },
|
|
16635
|
+
{
|
|
16636
|
+
name: "org",
|
|
16637
|
+
required: false,
|
|
16638
|
+
default: "hasna",
|
|
16639
|
+
description: "GitHub org"
|
|
16640
|
+
}
|
|
16641
|
+
],
|
|
16642
|
+
tasks: [
|
|
16643
|
+
{
|
|
16644
|
+
position: 0,
|
|
16645
|
+
title_pattern: "Scaffold {name} package structure",
|
|
16646
|
+
priority: "critical"
|
|
16647
|
+
},
|
|
16648
|
+
{
|
|
16649
|
+
position: 1,
|
|
16650
|
+
title_pattern: "Create {name} SQLite database + migrations",
|
|
16651
|
+
priority: "critical",
|
|
16652
|
+
depends_on_positions: [0]
|
|
16653
|
+
},
|
|
16654
|
+
{
|
|
16655
|
+
position: 2,
|
|
16656
|
+
title_pattern: "Implement {name} CRUD operations",
|
|
16657
|
+
priority: "high",
|
|
16658
|
+
depends_on_positions: [1]
|
|
16659
|
+
},
|
|
16660
|
+
{
|
|
16661
|
+
position: 3,
|
|
16662
|
+
title_pattern: "Build {name} MCP server with standard tools",
|
|
16663
|
+
priority: "high",
|
|
16664
|
+
depends_on_positions: [2]
|
|
16665
|
+
},
|
|
16666
|
+
{
|
|
16667
|
+
position: 4,
|
|
16668
|
+
title_pattern: "Build {name} CLI with Commander.js",
|
|
16669
|
+
priority: "high",
|
|
16670
|
+
depends_on_positions: [2]
|
|
16671
|
+
},
|
|
16672
|
+
{
|
|
16673
|
+
position: 5,
|
|
16674
|
+
title_pattern: "Build {name} REST API",
|
|
16675
|
+
priority: "medium",
|
|
16676
|
+
depends_on_positions: [2]
|
|
16677
|
+
},
|
|
16678
|
+
{
|
|
16679
|
+
position: 6,
|
|
16680
|
+
title_pattern: "Write unit tests for {name}",
|
|
16681
|
+
priority: "high",
|
|
16682
|
+
depends_on_positions: [2, 3, 4]
|
|
16683
|
+
},
|
|
16684
|
+
{
|
|
16685
|
+
position: 7,
|
|
16686
|
+
title_pattern: "Add Apache 2.0 license and README",
|
|
16687
|
+
priority: "medium",
|
|
16688
|
+
depends_on_positions: [0]
|
|
16689
|
+
},
|
|
16690
|
+
{
|
|
16691
|
+
position: 8,
|
|
16692
|
+
title_pattern: "Create GitHub repo {org}/{name}",
|
|
16693
|
+
priority: "medium",
|
|
16694
|
+
depends_on_positions: [7]
|
|
16695
|
+
},
|
|
16696
|
+
{
|
|
16697
|
+
position: 9,
|
|
16698
|
+
title_pattern: "Add local backup and restore workflow for {name}",
|
|
16699
|
+
priority: "medium",
|
|
16700
|
+
depends_on_positions: [1]
|
|
16701
|
+
},
|
|
16702
|
+
{
|
|
16703
|
+
position: 10,
|
|
16704
|
+
title_pattern: "Add agent tools (register_agent, heartbeat, set_focus, list_agents)",
|
|
16705
|
+
priority: "medium",
|
|
16706
|
+
depends_on_positions: [3]
|
|
16707
|
+
},
|
|
16708
|
+
{
|
|
16709
|
+
position: 11,
|
|
16710
|
+
title_pattern: "Publish @hasna/{name} to npm",
|
|
16711
|
+
priority: "high",
|
|
16712
|
+
depends_on_positions: [6, 7, 8]
|
|
16713
|
+
},
|
|
16714
|
+
{
|
|
16715
|
+
position: 12,
|
|
16716
|
+
title_pattern: "Install @hasna/{name} globally and verify",
|
|
16717
|
+
priority: "medium",
|
|
16718
|
+
depends_on_positions: [11]
|
|
16719
|
+
}
|
|
16720
|
+
]
|
|
16721
|
+
},
|
|
16722
|
+
{
|
|
16723
|
+
name: "release",
|
|
16724
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16725
|
+
category: "ops",
|
|
16726
|
+
description: "Version release workflow \u2014 test, changelog, publish, verify",
|
|
16727
|
+
variables: [
|
|
16728
|
+
{
|
|
16729
|
+
name: "version",
|
|
16730
|
+
required: true,
|
|
16731
|
+
description: "Release version (e.g. 1.2.0)"
|
|
16732
|
+
},
|
|
16733
|
+
{
|
|
16734
|
+
name: "package",
|
|
16735
|
+
required: false,
|
|
16736
|
+
default: "package",
|
|
16737
|
+
description: "Package name"
|
|
16738
|
+
}
|
|
16739
|
+
],
|
|
16740
|
+
tasks: [
|
|
16741
|
+
{
|
|
16742
|
+
position: 0,
|
|
16743
|
+
title_pattern: "Run full test suite for {package} {version}",
|
|
16744
|
+
priority: "critical"
|
|
16745
|
+
},
|
|
16746
|
+
{
|
|
16747
|
+
position: 1,
|
|
16748
|
+
title_pattern: "Update changelog for {version}",
|
|
16749
|
+
priority: "high",
|
|
16750
|
+
depends_on_positions: [0]
|
|
16751
|
+
},
|
|
16752
|
+
{
|
|
16753
|
+
position: 2,
|
|
16754
|
+
title_pattern: "Bump version to {version}",
|
|
16755
|
+
priority: "high",
|
|
16756
|
+
depends_on_positions: [1]
|
|
16757
|
+
},
|
|
16758
|
+
{
|
|
16759
|
+
position: 3,
|
|
16760
|
+
title_pattern: "Publish {package} {version}",
|
|
16761
|
+
priority: "critical",
|
|
16762
|
+
depends_on_positions: [2]
|
|
16763
|
+
},
|
|
16764
|
+
{
|
|
16765
|
+
position: 4,
|
|
16766
|
+
title_pattern: "Verify {package} {version} install and CLI",
|
|
16767
|
+
priority: "high",
|
|
16768
|
+
depends_on_positions: [3]
|
|
16769
|
+
}
|
|
16770
|
+
]
|
|
16771
|
+
},
|
|
16772
|
+
{
|
|
16773
|
+
name: "docs-refresh",
|
|
16774
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16775
|
+
category: "workflow",
|
|
16776
|
+
description: "Documentation refresh \u2014 audit, update, verify links",
|
|
16777
|
+
variables: [
|
|
16778
|
+
{
|
|
16779
|
+
name: "scope",
|
|
16780
|
+
required: true,
|
|
16781
|
+
description: "Docs scope (README, API, AGENTS.md)"
|
|
16782
|
+
}
|
|
16783
|
+
],
|
|
16784
|
+
tasks: [
|
|
16785
|
+
{
|
|
16786
|
+
position: 0,
|
|
16787
|
+
title_pattern: "Audit outdated sections in {scope}",
|
|
16788
|
+
priority: "high"
|
|
16789
|
+
},
|
|
16790
|
+
{
|
|
16791
|
+
position: 1,
|
|
16792
|
+
title_pattern: "Update {scope} for current behavior",
|
|
16793
|
+
priority: "high",
|
|
16794
|
+
depends_on_positions: [0]
|
|
16795
|
+
},
|
|
16796
|
+
{
|
|
16797
|
+
position: 2,
|
|
16798
|
+
title_pattern: "Verify examples in {scope} still work",
|
|
16799
|
+
priority: "medium",
|
|
16800
|
+
depends_on_positions: [1]
|
|
16801
|
+
},
|
|
16802
|
+
{
|
|
16803
|
+
position: 3,
|
|
16804
|
+
title_pattern: "Fix broken links in {scope}",
|
|
16805
|
+
priority: "medium",
|
|
16806
|
+
depends_on_positions: [1]
|
|
16807
|
+
}
|
|
16808
|
+
]
|
|
16809
|
+
},
|
|
16810
|
+
{
|
|
16811
|
+
name: "migration",
|
|
16812
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16813
|
+
category: "ops",
|
|
16814
|
+
description: "Schema/data migration workflow",
|
|
16815
|
+
variables: [
|
|
16816
|
+
{
|
|
16817
|
+
name: "migration",
|
|
16818
|
+
required: true,
|
|
16819
|
+
description: "Migration name or ID"
|
|
16820
|
+
},
|
|
16821
|
+
{
|
|
16822
|
+
name: "target",
|
|
16823
|
+
required: false,
|
|
16824
|
+
default: "sqlite",
|
|
16825
|
+
description: "Target store"
|
|
16826
|
+
}
|
|
16827
|
+
],
|
|
16828
|
+
tasks: [
|
|
16829
|
+
{
|
|
16830
|
+
position: 0,
|
|
16831
|
+
title_pattern: "Write forward migration for {migration} ({target})",
|
|
16832
|
+
priority: "critical"
|
|
16833
|
+
},
|
|
16834
|
+
{
|
|
16835
|
+
position: 1,
|
|
16836
|
+
title_pattern: "Add migration tests for {migration}",
|
|
16837
|
+
priority: "high",
|
|
16838
|
+
depends_on_positions: [0]
|
|
16839
|
+
},
|
|
16840
|
+
{
|
|
16841
|
+
position: 2,
|
|
16842
|
+
title_pattern: "Run migration dry-run on copy",
|
|
16843
|
+
priority: "high",
|
|
16844
|
+
depends_on_positions: [1]
|
|
16845
|
+
},
|
|
16846
|
+
{
|
|
16847
|
+
position: 3,
|
|
16848
|
+
title_pattern: "Apply {migration} and verify integrity",
|
|
16849
|
+
priority: "critical",
|
|
16850
|
+
depends_on_positions: [2]
|
|
16851
|
+
},
|
|
16852
|
+
{
|
|
16853
|
+
position: 4,
|
|
16854
|
+
title_pattern: "Document rollback for {migration}",
|
|
16855
|
+
priority: "medium",
|
|
16856
|
+
depends_on_positions: [3]
|
|
16857
|
+
}
|
|
16858
|
+
]
|
|
16859
|
+
},
|
|
16860
|
+
{
|
|
16861
|
+
name: "incident-response",
|
|
16862
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16863
|
+
category: "ops",
|
|
16864
|
+
description: "Incident triage, mitigation, postmortem",
|
|
16865
|
+
variables: [
|
|
16866
|
+
{ name: "incident", required: true, description: "Incident summary" }
|
|
16867
|
+
],
|
|
16868
|
+
tasks: [
|
|
16869
|
+
{
|
|
16870
|
+
position: 0,
|
|
16871
|
+
title_pattern: "Triage: {incident}",
|
|
16872
|
+
priority: "critical",
|
|
16873
|
+
tags: ["incident"]
|
|
16874
|
+
},
|
|
16875
|
+
{
|
|
16876
|
+
position: 1,
|
|
16877
|
+
title_pattern: "Mitigate impact of {incident}",
|
|
16878
|
+
priority: "critical",
|
|
16879
|
+
depends_on_positions: [0],
|
|
16880
|
+
tags: ["incident"]
|
|
16881
|
+
},
|
|
16882
|
+
{
|
|
16883
|
+
position: 2,
|
|
16884
|
+
title_pattern: "Identify root cause: {incident}",
|
|
16885
|
+
priority: "critical",
|
|
16886
|
+
depends_on_positions: [1]
|
|
16887
|
+
},
|
|
16888
|
+
{
|
|
16889
|
+
position: 3,
|
|
16890
|
+
title_pattern: "Permanent fix for {incident}",
|
|
16891
|
+
priority: "high",
|
|
16892
|
+
depends_on_positions: [2]
|
|
16893
|
+
},
|
|
16894
|
+
{
|
|
16895
|
+
position: 4,
|
|
16896
|
+
title_pattern: "Write postmortem for {incident}",
|
|
16897
|
+
priority: "medium",
|
|
16898
|
+
depends_on_positions: [3]
|
|
16899
|
+
},
|
|
16900
|
+
{
|
|
16901
|
+
position: 5,
|
|
16902
|
+
title_pattern: "Add monitoring/alert for {incident} class",
|
|
16903
|
+
priority: "medium",
|
|
16904
|
+
depends_on_positions: [4]
|
|
16905
|
+
}
|
|
16906
|
+
]
|
|
16907
|
+
}
|
|
16908
|
+
];
|
|
16909
|
+
function templateMetadata(template) {
|
|
16910
|
+
return {
|
|
16911
|
+
source: BUILTIN_TEMPLATE_LIBRARY_SOURCE,
|
|
16912
|
+
library_version: template.version,
|
|
16913
|
+
category: template.category,
|
|
16914
|
+
template_file: `${template.name}.json`,
|
|
16915
|
+
local_only: true,
|
|
16916
|
+
marketplace_free: true
|
|
16917
|
+
};
|
|
16918
|
+
}
|
|
16919
|
+
function listBuiltinTemplates() {
|
|
16920
|
+
return BUILTIN_TEMPLATES.map((template) => ({
|
|
16921
|
+
...template,
|
|
16922
|
+
variables: template.variables.map((variable) => ({ ...variable })),
|
|
16923
|
+
tasks: template.tasks.map((task) => ({
|
|
16924
|
+
...task,
|
|
16925
|
+
tags: task.tags ? [...task.tags] : undefined
|
|
16926
|
+
}))
|
|
16927
|
+
}));
|
|
16928
|
+
}
|
|
16929
|
+
function getBuiltinTemplate(name) {
|
|
16930
|
+
return listBuiltinTemplates().find((template) => template.name === name) ?? null;
|
|
16931
|
+
}
|
|
16932
|
+
function exportBuiltinTemplate(name) {
|
|
16933
|
+
const template = getBuiltinTemplate(name);
|
|
16934
|
+
if (!template)
|
|
16935
|
+
throw new Error(`Built-in template not found: ${name}`);
|
|
16936
|
+
return {
|
|
16937
|
+
name: template.name,
|
|
16938
|
+
title_pattern: `${template.name}: {${template.variables[0]?.name ?? "name"}}`,
|
|
16939
|
+
description: template.description,
|
|
16940
|
+
priority: "medium",
|
|
16941
|
+
tags: [template.category, "local-template"],
|
|
16942
|
+
variables: template.variables,
|
|
16943
|
+
project_id: null,
|
|
16944
|
+
plan_id: null,
|
|
16945
|
+
metadata: templateMetadata(template),
|
|
16946
|
+
tasks: template.tasks.map((task) => ({
|
|
16947
|
+
position: task.position,
|
|
16948
|
+
title_pattern: task.title_pattern,
|
|
16949
|
+
description: task.description ?? null,
|
|
16950
|
+
priority: task.priority ?? "medium",
|
|
16951
|
+
tags: task.tags ?? [template.category],
|
|
16952
|
+
task_type: task.task_type ?? null,
|
|
16953
|
+
condition: task.condition ?? null,
|
|
16954
|
+
include_template_id: task.include_template_id ?? null,
|
|
16955
|
+
depends_on_positions: task.depends_on_positions ?? task.depends_on ?? [],
|
|
16956
|
+
metadata: task.metadata ?? { category: template.category }
|
|
16957
|
+
}))
|
|
16958
|
+
};
|
|
16959
|
+
}
|
|
16960
|
+
function exportBuiltinTemplateFiles() {
|
|
16961
|
+
return BUILTIN_TEMPLATES.map((template) => ({
|
|
16962
|
+
filename: `${template.name}.json`,
|
|
16963
|
+
template: exportBuiltinTemplate(template.name)
|
|
16964
|
+
}));
|
|
16965
|
+
}
|
|
16966
|
+
function writeBuiltinTemplateFiles(directory) {
|
|
16967
|
+
mkdirSync5(directory, { recursive: true });
|
|
16968
|
+
const files = [];
|
|
16969
|
+
for (const entry of exportBuiltinTemplateFiles()) {
|
|
16970
|
+
const path = join7(directory, entry.filename);
|
|
16971
|
+
writeFileSync3(path, `${JSON.stringify(entry.template, null, 2)}
|
|
16972
|
+
`, "utf-8");
|
|
16973
|
+
files.push(path);
|
|
16974
|
+
}
|
|
16975
|
+
return { directory, written: files.length, files };
|
|
16976
|
+
}
|
|
16977
|
+
function builtinTemplateInputs() {
|
|
16978
|
+
return listBuiltinTemplates().map((bt) => ({
|
|
16979
|
+
name: bt.name,
|
|
16980
|
+
title_pattern: `${bt.name}: {${bt.variables[0]?.name || "name"}}`,
|
|
16981
|
+
description: bt.description,
|
|
16982
|
+
tags: [bt.category, "local-template"],
|
|
16983
|
+
variables: bt.variables,
|
|
16984
|
+
metadata: templateMetadata(bt),
|
|
16985
|
+
tasks: bt.tasks.map((task) => ({
|
|
16986
|
+
title_pattern: task.title_pattern,
|
|
16987
|
+
description: task.description,
|
|
16988
|
+
priority: task.priority,
|
|
16989
|
+
tags: task.tags,
|
|
16990
|
+
task_type: task.task_type,
|
|
16991
|
+
depends_on: task.depends_on_positions || task.depends_on,
|
|
16992
|
+
metadata: task.metadata
|
|
16993
|
+
}))
|
|
16994
|
+
}));
|
|
16995
|
+
}
|
|
16996
|
+
|
|
16997
|
+
// src/lib/plan-schedule.ts
|
|
16998
|
+
function validatePlanSchedule(value) {
|
|
16999
|
+
for (const key of ["start_date", "end_date"]) {
|
|
17000
|
+
const date = value[key];
|
|
17001
|
+
if (date === undefined || date === null)
|
|
17002
|
+
continue;
|
|
17003
|
+
if (typeof date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date)
|
|
17004
|
+
return `${key} must be a valid ISO calendar date (YYYY-MM-DD)`;
|
|
17005
|
+
}
|
|
17006
|
+
if (typeof value.start_date === "string" && typeof value.end_date === "string" && value.start_date > value.end_date)
|
|
17007
|
+
return "end_date must not precede start_date";
|
|
17008
|
+
return null;
|
|
17009
|
+
}
|
|
17010
|
+
|
|
17011
|
+
// src/storage/atomic-project-migration.ts
|
|
15227
17012
|
init_types();
|
|
15228
|
-
import {
|
|
15229
|
-
|
|
17013
|
+
import { createHash as createHash5 } from "crypto";
|
|
17014
|
+
var families = { projects: "projects", tasks: "tasks", plans: "plans", taskLists: "task_lists", auditHistory: "audit_history" };
|
|
15230
17015
|
|
|
15231
|
-
|
|
15232
|
-
|
|
15233
|
-
|
|
15234
|
-
|
|
15235
|
-
|
|
17016
|
+
class AtomicMigrationError extends Error {
|
|
17017
|
+
status;
|
|
17018
|
+
record;
|
|
17019
|
+
constructor(status, message, record) {
|
|
17020
|
+
super(message);
|
|
17021
|
+
this.status = status;
|
|
17022
|
+
this.record = record;
|
|
17023
|
+
this.name = "AtomicMigrationError";
|
|
17024
|
+
}
|
|
15236
17025
|
}
|
|
15237
|
-
function
|
|
15238
|
-
|
|
17026
|
+
function fail(message, status = 409, record) {
|
|
17027
|
+
throw new AtomicMigrationError(status, message, record && { object_type: record.object_type, object_id: record.object_id });
|
|
17028
|
+
}
|
|
17029
|
+
function plain(value) {
|
|
17030
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
17031
|
+
}
|
|
17032
|
+
function canonical(value, depth = 0) {
|
|
17033
|
+
if (depth > 32)
|
|
17034
|
+
fail("Migration JSON depth exceeds 32", 400);
|
|
17035
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
17036
|
+
return JSON.stringify(value);
|
|
17037
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
17038
|
+
return JSON.stringify(value);
|
|
17039
|
+
if (Array.isArray(value))
|
|
17040
|
+
return "[" + value.map((v) => canonical(v, depth + 1)).join(",") + "]";
|
|
17041
|
+
if (plain(value))
|
|
17042
|
+
return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonical(value[k], depth + 1)).join(",") + "}";
|
|
17043
|
+
return fail("Migration requires finite JSON values", 400);
|
|
17044
|
+
}
|
|
17045
|
+
function atomicMigrationHash(value) {
|
|
17046
|
+
return createHash5("sha256").update(canonical(value)).digest("hex");
|
|
17047
|
+
}
|
|
17048
|
+
function stamp(value) {
|
|
17049
|
+
if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value)
|
|
17050
|
+
fail("Migration clocks require canonical millisecond UTC timestamps", 400);
|
|
17051
|
+
return value;
|
|
17052
|
+
}
|
|
17053
|
+
function version(value) {
|
|
17054
|
+
if (value == null)
|
|
17055
|
+
return 0;
|
|
17056
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0)
|
|
17057
|
+
fail("Invalid migration version", 400);
|
|
17058
|
+
return value;
|
|
17059
|
+
}
|
|
17060
|
+
function key(row) {
|
|
17061
|
+
return row.object_type + "\x00" + row.object_id;
|
|
17062
|
+
}
|
|
17063
|
+
function normalized(row) {
|
|
17064
|
+
return { ...row, source_machine_id: row.source_machine_id ?? null, updated_at: new Date(row.updated_at).toISOString(), deleted_at: row.deleted_at ? new Date(row.deleted_at).toISOString() : null };
|
|
17065
|
+
}
|
|
17066
|
+
function digest(row) {
|
|
17067
|
+
return atomicMigrationHash(normalized(row));
|
|
17068
|
+
}
|
|
17069
|
+
function compare(a, b) {
|
|
17070
|
+
return Date.parse(String(a.updated_at)) - Date.parse(String(b.updated_at)) || version(a.version) - version(b.version);
|
|
17071
|
+
}
|
|
17072
|
+
function identity(value) {
|
|
17073
|
+
if (typeof value !== "string" || !value.trim() || value.length > 256 || value.includes("\x00"))
|
|
17074
|
+
fail("Invalid migration record identity", 400);
|
|
17075
|
+
return value;
|
|
17076
|
+
}
|
|
17077
|
+
function validate(request) {
|
|
17078
|
+
if (!plain(request) || request.schema_version !== 1 || typeof request.operation_id !== "string" || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.operation_id))
|
|
17079
|
+
fail("Invalid migration operation identity/version", 400);
|
|
17080
|
+
if (Object.keys(request).some((k) => !["schema_version", "operation_id", "expected_authority", "snapshot", "snapshot_hash"].includes(k)) || Buffer.byteLength(canonical(request)) > 2200000)
|
|
17081
|
+
fail("Unsupported or oversized migration envelope", 400);
|
|
17082
|
+
if (!plain(request.expected_authority))
|
|
17083
|
+
fail("Expected migration authority is required", 400);
|
|
17084
|
+
identity(request.expected_authority.tenant_id);
|
|
17085
|
+
identity(request.expected_authority.kid);
|
|
17086
|
+
identity(request.expected_authority.deployment_id);
|
|
17087
|
+
if (!plain(request.snapshot))
|
|
17088
|
+
fail("Migration snapshot must be an object", 400);
|
|
17089
|
+
const encoded = canonical(request.snapshot);
|
|
17090
|
+
if (Buffer.byteLength(encoded) > 2 * 1024 * 1024)
|
|
17091
|
+
fail("Migration snapshot exceeds 2 MiB", 413);
|
|
17092
|
+
if (atomicMigrationHash(request.snapshot) !== request.snapshot_hash)
|
|
17093
|
+
fail("Migration snapshot hash mismatch", 400);
|
|
17094
|
+
const allowed = new Set([...Object.keys(families), "tombstones", "exportedAt", "source"]);
|
|
17095
|
+
for (const [name, value] of Object.entries(request.snapshot))
|
|
17096
|
+
if (!allowed.has(name) && !(Array.isArray(value) && value.length === 0))
|
|
17097
|
+
fail(`Unsupported atomic migration family: ${name}`, 400);
|
|
17098
|
+
const rows = [];
|
|
17099
|
+
for (const [name, type] of Object.entries(families)) {
|
|
17100
|
+
const values = request.snapshot[name] ?? [];
|
|
17101
|
+
if (!Array.isArray(values))
|
|
17102
|
+
fail(`Invalid migration array: ${name}`, 400);
|
|
17103
|
+
for (const value of values) {
|
|
17104
|
+
if (!plain(value))
|
|
17105
|
+
fail("Invalid migration record", 400);
|
|
17106
|
+
const id = identity(value.id);
|
|
17107
|
+
const clock = stamp(value.updated_at ?? value.created_at);
|
|
17108
|
+
if (type === "tasks") {
|
|
17109
|
+
if (typeof value.title !== "string" || !TASK_STATUSES.includes(value.status) || !TASK_PRIORITIES.includes(value.priority))
|
|
17110
|
+
fail("Invalid task migration payload", 400);
|
|
17111
|
+
if (value.locked_by || value.runner_id)
|
|
17112
|
+
fail("Active task leases require separate migration reconciliation", 409);
|
|
17113
|
+
}
|
|
17114
|
+
if (type === "plans" && (typeof value.name !== "string" || !PLAN_STATUSES.includes(value.status)))
|
|
17115
|
+
fail("Invalid plan migration payload", 400);
|
|
17116
|
+
if (type === "projects" && typeof value.name !== "string")
|
|
17117
|
+
fail("Invalid project migration payload", 400);
|
|
17118
|
+
if (type === "audit_history" && (typeof value.task_id !== "string" || typeof value.action !== "string"))
|
|
17119
|
+
fail("Invalid history migration payload", 400);
|
|
17120
|
+
rows.push({ object_type: type, object_id: id, payload: value, updated_at: clock, deleted_at: null, version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
|
|
17121
|
+
}
|
|
17122
|
+
}
|
|
17123
|
+
const tombstones = request.snapshot.tombstones ?? [];
|
|
17124
|
+
if (!Array.isArray(tombstones))
|
|
17125
|
+
fail("Invalid tombstone array", 400);
|
|
17126
|
+
for (const value of tombstones) {
|
|
17127
|
+
if (!plain(value) || value.object_type !== "projects")
|
|
17128
|
+
fail("Atomic migration supports only project tombstones", 400);
|
|
17129
|
+
if (!plain(value.payload))
|
|
17130
|
+
fail("Project tombstone must preserve its original payload", 400);
|
|
17131
|
+
if (stamp(value.updated_at) < stamp(value.deleted_at))
|
|
17132
|
+
fail("Tombstone update clock precedes deletion", 400);
|
|
17133
|
+
const id = identity(value.object_id);
|
|
17134
|
+
if (value.payload.id !== id)
|
|
17135
|
+
fail("Tombstone payload identity mismatch", 400);
|
|
17136
|
+
rows.push({ object_type: "projects", object_id: id, payload: value.payload, updated_at: stamp(value.updated_at), deleted_at: stamp(value.deleted_at), version: value.version == null ? null : version(value.version), source_machine_id: typeof value.source_machine_id === "string" ? value.source_machine_id : null });
|
|
17137
|
+
}
|
|
17138
|
+
if (!rows.length || rows.length > 1000)
|
|
17139
|
+
fail("Atomic migration requires 1-1000 records", 400);
|
|
17140
|
+
const unique4 = new Map;
|
|
17141
|
+
for (const row of rows) {
|
|
17142
|
+
const previous = unique4.get(key(row));
|
|
17143
|
+
if (previous && digest(previous) !== digest(row))
|
|
17144
|
+
fail("Divergent duplicate migration identity", 409, row);
|
|
17145
|
+
unique4.set(key(row), row);
|
|
17146
|
+
}
|
|
17147
|
+
return [...unique4.values()];
|
|
17148
|
+
}
|
|
17149
|
+
function validateGraph(rows) {
|
|
17150
|
+
const live = [...rows.values()].filter((r) => !r.deleted_at);
|
|
17151
|
+
const get = (type, id) => typeof id === "string" ? rows.get(type + "\x00" + id) : undefined;
|
|
17152
|
+
for (const row of live) {
|
|
17153
|
+
if (row.object_type === "audit_history") {
|
|
17154
|
+
if (!get("tasks", row.payload.task_id))
|
|
17155
|
+
fail("Migration history references an unknown task", 409, row);
|
|
17156
|
+
continue;
|
|
17157
|
+
}
|
|
17158
|
+
if (!["projects", "tasks", "plans", "task_lists"].includes(row.object_type))
|
|
17159
|
+
continue;
|
|
17160
|
+
if (row.object_type === "tasks" && row.payload.plan_id) {
|
|
17161
|
+
const plan = get("plans", row.payload.plan_id);
|
|
17162
|
+
if (plan && !plan.deleted_at && (plan.payload.project_id ?? null) !== (row.payload.project_id ?? null))
|
|
17163
|
+
fail("Migration task project conflicts with its plan", 409, row);
|
|
17164
|
+
}
|
|
17165
|
+
if (["tasks", "plans"].includes(row.object_type) && row.payload.task_list_id != null) {
|
|
17166
|
+
const selector = row.payload.task_list_id;
|
|
17167
|
+
if (typeof selector !== "string" || !selector)
|
|
17168
|
+
fail("Invalid migration task-list selector", 409, row);
|
|
17169
|
+
const candidates = live.filter((candidate) => candidate.object_type === "task_lists" && (candidate.payload.project_id ?? null) === (row.payload.project_id ?? null) && (candidate.object_id === selector || candidate.payload.slug === selector));
|
|
17170
|
+
const project = get("projects", row.payload.project_id);
|
|
17171
|
+
const canonicalDefault = project && !project.deleted_at && project.payload.task_list_id === selector;
|
|
17172
|
+
if (candidates.length > 1 || !candidates.length && !canonicalDefault)
|
|
17173
|
+
fail("Migration task-list reference is missing or ambiguous; explicit reconciliation required", 409, row);
|
|
17174
|
+
}
|
|
17175
|
+
const refs = [];
|
|
17176
|
+
if (row.object_type === "projects")
|
|
17177
|
+
refs.push(["projects", row.payload.parent_id]);
|
|
17178
|
+
else
|
|
17179
|
+
refs.push(["projects", row.payload.project_id]);
|
|
17180
|
+
if (row.object_type === "tasks")
|
|
17181
|
+
refs.push(["tasks", row.payload.parent_id], ["plans", row.payload.plan_id]);
|
|
17182
|
+
for (const [type, id] of refs) {
|
|
17183
|
+
if (id == null)
|
|
17184
|
+
continue;
|
|
17185
|
+
if (typeof id !== "string" || !id || !get(type, id) || get(type, id).deleted_at)
|
|
17186
|
+
fail("Migration reference is missing or deleted", 409, row);
|
|
17187
|
+
}
|
|
17188
|
+
if (row.object_type === "tasks" && row.payload.parent_id) {
|
|
17189
|
+
const parent = get("tasks", row.payload.parent_id);
|
|
17190
|
+
if ((parent.payload.project_id ?? null) !== (row.payload.project_id ?? null))
|
|
17191
|
+
fail("Migration task parent crosses project scope", 409, row);
|
|
17192
|
+
}
|
|
17193
|
+
}
|
|
17194
|
+
for (const type of ["projects", "tasks"])
|
|
17195
|
+
for (const row of live.filter((r) => r.object_type === type)) {
|
|
17196
|
+
const seen = new Set;
|
|
17197
|
+
let cursor = row;
|
|
17198
|
+
while (cursor) {
|
|
17199
|
+
if (seen.has(cursor.object_id))
|
|
17200
|
+
fail("Migration hierarchy contains a cycle", 409, row);
|
|
17201
|
+
seen.add(cursor.object_id);
|
|
17202
|
+
cursor = get(type, cursor.payload.parent_id);
|
|
17203
|
+
}
|
|
17204
|
+
}
|
|
17205
|
+
const projects = live.filter((r) => r.object_type === "projects").map((r) => r.payload);
|
|
17206
|
+
const lists = live.filter((r) => r.object_type === "task_lists").map((r) => r.payload);
|
|
17207
|
+
const errors = validateSnapshotRoutingRecords(projects, lists);
|
|
17208
|
+
if (errors.length)
|
|
17209
|
+
fail("Migration project/list routing conflict");
|
|
17210
|
+
}
|
|
17211
|
+
function createAtomicProjectMigration(options) {
|
|
17212
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.table))
|
|
17213
|
+
throw new Error("Unsafe migration table");
|
|
17214
|
+
return { async apply(request) {
|
|
17215
|
+
const incoming = validate(request);
|
|
17216
|
+
if (!options.client.transaction)
|
|
17217
|
+
fail("Atomic migration requires a transaction-capable backend", 501);
|
|
17218
|
+
await options.ensureSchema();
|
|
17219
|
+
return options.client.transaction(async (client) => {
|
|
17220
|
+
const deadline = Date.now() + 20000;
|
|
17221
|
+
await client.query("SET LOCAL lock_timeout = '5s'");
|
|
17222
|
+
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
17223
|
+
await client.query("SET LOCAL synchronous_commit = 'on'");
|
|
17224
|
+
const durability = await client.query("SELECT current_setting('fsync') AS fsync,current_setting('full_page_writes') AS full_page_writes,current_setting('synchronous_commit') AS synchronous_commit");
|
|
17225
|
+
const durable = durability.rows[0];
|
|
17226
|
+
if (durable?.fsync !== "on" || durable.full_page_writes !== "on" || durable.synchronous_commit !== "on")
|
|
17227
|
+
fail("Migration database durability settings are not ready", 503);
|
|
17228
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity',0))", [options.service]);
|
|
17229
|
+
await client.query(`LOCK TABLE ${options.table} IN SHARE ROW EXCLUSIVE MODE`);
|
|
17230
|
+
const journalId = atomicMigrationHash({ operation_id: request.operation_id });
|
|
17231
|
+
const journal = await client.query(`SELECT payload->>'request_hash' AS request_hash,payload->'receipt' AS receipt FROM ${options.table} WHERE service=$1 AND object_type='atomic_project_migrations' AND object_id=$2`, [options.service, journalId]);
|
|
17232
|
+
const requestHash = atomicMigrationHash(request);
|
|
17233
|
+
if (journal.rows[0]) {
|
|
17234
|
+
if (journal.rows[0].request_hash !== requestHash)
|
|
17235
|
+
fail("Migration operation identity was already used for another request");
|
|
17236
|
+
return journal.rows[0].receipt;
|
|
17237
|
+
}
|
|
17238
|
+
const bounds = await client.query(`SELECT count(*)::text AS records,COALESCE(sum(octet_length(payload::text)),0)::text AS bytes,COALESCE(max(octet_length(payload::text)),0)::text AS largest FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history')`, [options.service]);
|
|
17239
|
+
const size = bounds.rows[0];
|
|
17240
|
+
if (!size || Number(size.records) > 1e4 || Number(size.bytes) > 16 * 1024 * 1024 || Number(size.largest) > 2 * 1024 * 1024)
|
|
17241
|
+
fail("Atomic migration destination exceeds its bounded row/byte scope", 413);
|
|
17242
|
+
const selected = await client.query(`SELECT object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id FROM ${options.table} WHERE service=$1 AND object_type IN ('projects','tasks','plans','task_lists','audit_history') LIMIT 10001 FOR UPDATE`, [options.service]);
|
|
17243
|
+
if (selected.rows.length > 1e4)
|
|
17244
|
+
fail("Atomic migration destination exceeds bounded 10000-record scope", 413);
|
|
17245
|
+
for (const row of selected.rows) {
|
|
17246
|
+
canonical(row.payload);
|
|
17247
|
+
if (Date.now() > deadline)
|
|
17248
|
+
fail("Migration destination validation exceeded time budget", 503);
|
|
17249
|
+
}
|
|
17250
|
+
const original = new Map(selected.rows.map((r) => [key(r), normalized(r)]));
|
|
17251
|
+
const current = new Map(original);
|
|
17252
|
+
const receipts = [];
|
|
17253
|
+
const receipt = (source, before, after, outcome) => receipts.push({ object_type: source.object_type, object_id: source.object_id, outcome, before_hash: before ? digest(before) : null, after_hash: digest(after), source_hash: digest(source) });
|
|
17254
|
+
for (const row of incoming.filter((r) => !r.deleted_at)) {
|
|
17255
|
+
const before = current.get(key(row));
|
|
17256
|
+
if (before) {
|
|
17257
|
+
if (digest(before) === digest(row)) {
|
|
17258
|
+
receipt(row, before, before, "identical");
|
|
17259
|
+
continue;
|
|
17260
|
+
}
|
|
17261
|
+
if (before.object_type === "tasks" && (before.payload.locked_by || before.payload.runner_id))
|
|
17262
|
+
fail("Active destination task lease requires reconciliation", 409, before);
|
|
17263
|
+
if (row.object_type === "audit_history")
|
|
17264
|
+
fail("Immutable history identity conflict", 409, row);
|
|
17265
|
+
const clock = compare(row, before);
|
|
17266
|
+
if (clock === 0)
|
|
17267
|
+
fail("Ambiguous equal-clock migration conflict", 409, row);
|
|
17268
|
+
if (clock < 0) {
|
|
17269
|
+
receipt(row, before, before, "superseded");
|
|
17270
|
+
continue;
|
|
17271
|
+
}
|
|
17272
|
+
if (before.deleted_at)
|
|
17273
|
+
fail("Migration cannot implicitly resurrect a deleted record", 409, row);
|
|
17274
|
+
}
|
|
17275
|
+
current.set(key(row), row);
|
|
17276
|
+
receipt(row, before, row, before ? "updated" : "inserted");
|
|
17277
|
+
}
|
|
17278
|
+
for (const row of incoming.filter((r) => r.deleted_at)) {
|
|
17279
|
+
const before = current.get(key(row));
|
|
17280
|
+
if (before) {
|
|
17281
|
+
if (digest(before) === digest(row)) {
|
|
17282
|
+
receipt(row, before, before, "identical");
|
|
17283
|
+
continue;
|
|
17284
|
+
}
|
|
17285
|
+
const clock = compare(row, before);
|
|
17286
|
+
if (clock === 0)
|
|
17287
|
+
fail("Ambiguous equal-clock lifecycle conflict", 409, row);
|
|
17288
|
+
if (clock < 0) {
|
|
17289
|
+
receipt(row, before, before, "superseded");
|
|
17290
|
+
continue;
|
|
17291
|
+
}
|
|
17292
|
+
}
|
|
17293
|
+
for (const [id, linked] of current) {
|
|
17294
|
+
if (linked.deleted_at)
|
|
17295
|
+
continue;
|
|
17296
|
+
const field = linked.object_type === "projects" ? "parent_id" : "project_id";
|
|
17297
|
+
if (!["projects", "tasks", "plans", "task_lists"].includes(linked.object_type) || linked.payload[field] !== row.object_id)
|
|
17298
|
+
continue;
|
|
17299
|
+
if (compare(linked, row) > 0)
|
|
17300
|
+
fail("A newer linked record conflicts with the historical project deletion", 409, linked);
|
|
17301
|
+
if (linked.object_type === "tasks" && (linked.payload.locked_by || linked.payload.runner_id))
|
|
17302
|
+
fail("Active linked task lease requires reconciliation", 409, linked);
|
|
17303
|
+
const detached = { ...linked, payload: { ...linked.payload, [field]: null, updated_at: row.updated_at }, updated_at: row.updated_at };
|
|
17304
|
+
if (linked.object_type === "tasks") {
|
|
17305
|
+
detached.version = version(linked.version) + 1;
|
|
17306
|
+
detached.payload.version = detached.version;
|
|
17307
|
+
}
|
|
17308
|
+
current.set(id, detached);
|
|
17309
|
+
receipt(linked, linked, detached, "detached");
|
|
17310
|
+
}
|
|
17311
|
+
current.set(key(row), row);
|
|
17312
|
+
receipt(row, before, row, "deleted");
|
|
17313
|
+
}
|
|
17314
|
+
validateGraph(current);
|
|
17315
|
+
for (const [id, row] of current) {
|
|
17316
|
+
if (Date.now() > deadline)
|
|
17317
|
+
fail("Atomic migration exceeded its transaction time budget", 503);
|
|
17318
|
+
const before = original.get(id);
|
|
17319
|
+
if (before && digest(before) === digest(row))
|
|
17320
|
+
continue;
|
|
17321
|
+
await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at,deleted_at,version,source_machine_id) VALUES($1,$2,$3,$4::jsonb,$5::timestamptz,$6::timestamptz,$7,$8) ON CONFLICT(service,object_type,object_id) DO UPDATE SET payload=EXCLUDED.payload,updated_at=EXCLUDED.updated_at,deleted_at=EXCLUDED.deleted_at,version=EXCLUDED.version,source_machine_id=EXCLUDED.source_machine_id`, [options.service, row.object_type, row.object_id, row.payload, row.updated_at, row.deleted_at, row.version, row.source_machine_id ?? null]);
|
|
17322
|
+
}
|
|
17323
|
+
const result = { schema_version: 1, operation_id: request.operation_id, snapshot_hash: request.snapshot_hash, authority: request.expected_authority, status: "complete", records: receipts };
|
|
17324
|
+
const changedKeys = new Set(receipts.map((row) => key(row)));
|
|
17325
|
+
const evidence = { request_hash: requestHash, request, receipt: result, before: [...original.values()].filter((row) => changedKeys.has(key(row))) };
|
|
17326
|
+
if (Date.now() > deadline)
|
|
17327
|
+
fail("Atomic migration exceeded its transaction time budget", 503);
|
|
17328
|
+
await client.query(`INSERT INTO ${options.table}(service,object_type,object_id,payload,updated_at) VALUES($1,'atomic_project_migrations',$2,$3::jsonb,now())`, [options.service, journalId, evidence]);
|
|
17329
|
+
return result;
|
|
17330
|
+
});
|
|
17331
|
+
} };
|
|
17332
|
+
}
|
|
17333
|
+
|
|
17334
|
+
// src/storage/postgres-adapter.ts
|
|
17335
|
+
init_types();
|
|
17336
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
17337
|
+
init_creator_identity();
|
|
17338
|
+
|
|
17339
|
+
// src/lib/instant-compare.ts
|
|
17340
|
+
var SQLITE_STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(?:Z|([+-])(\d{2}):(\d{2}))?)?$/;
|
|
17341
|
+
var DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
17342
|
+
function isLeapYear(year) {
|
|
17343
|
+
return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
|
|
17344
|
+
}
|
|
17345
|
+
function sqliteJulianDay(value) {
|
|
17346
|
+
const m = SQLITE_STAMP.exec(value);
|
|
15239
17347
|
if (!m)
|
|
15240
17348
|
return null;
|
|
15241
17349
|
const year = Number(m[1]);
|
|
@@ -15269,8 +17377,8 @@ function sqliteJulianDay(value) {
|
|
|
15269
17377
|
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
15270
17378
|
return (ms + micros / 1000) / 86400000 + 2440587.5 - offsetMinutes / 1440;
|
|
15271
17379
|
}
|
|
15272
|
-
function changedSinceStampNewer(
|
|
15273
|
-
const stampJd = sqliteJulianDay(
|
|
17380
|
+
function changedSinceStampNewer(stamp2, since) {
|
|
17381
|
+
const stampJd = sqliteJulianDay(stamp2);
|
|
15274
17382
|
if (stampJd === null)
|
|
15275
17383
|
return true;
|
|
15276
17384
|
const sinceJd = sqliteJulianDay(since);
|
|
@@ -15504,6 +17612,10 @@ class PostgresTodosSyncStore {
|
|
|
15504
17612
|
}
|
|
15505
17613
|
}
|
|
15506
17614
|
async pushSnapshot(snapshot, context = {}) {
|
|
17615
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines"))
|
|
17616
|
+
throw new Error("Machine tombstones require explicit registry lifecycle operations");
|
|
17617
|
+
if (snapshot.machines !== undefined)
|
|
17618
|
+
validateMachines(snapshot.machines);
|
|
15507
17619
|
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
15508
17620
|
if (routingErrors.length > 0) {
|
|
15509
17621
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
@@ -15526,6 +17638,14 @@ class PostgresTodosSyncStore {
|
|
|
15526
17638
|
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
15527
17639
|
}
|
|
15528
17640
|
const result = { records: 0, objectTypes: {} };
|
|
17641
|
+
if (snapshot.machines?.length) {
|
|
17642
|
+
if (!this.client.transaction)
|
|
17643
|
+
throw new Error("Machine snapshot migration requires transaction(callback)");
|
|
17644
|
+
const registry = createPostgresMachineRegistry({ query: client.query.bind(client), transaction: (fn) => fn(client) }, this.service, this.tableName, async () => {});
|
|
17645
|
+
const receipt = await registry.execute({ action: "import", machines: snapshot.machines });
|
|
17646
|
+
result.records += receipt.inserted + receipt.skipped;
|
|
17647
|
+
result.objectTypes.machines = receipt.inserted + receipt.skipped;
|
|
17648
|
+
}
|
|
15529
17649
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
15530
17650
|
for (const entry of snapshotEntries(snapshot)) {
|
|
15531
17651
|
if (entry.deletedAt === null)
|
|
@@ -15689,6 +17809,7 @@ function rowsToSnapshot(rows) {
|
|
|
15689
17809
|
tasks: [],
|
|
15690
17810
|
projects: [],
|
|
15691
17811
|
projectMachinePaths: [],
|
|
17812
|
+
machines: [],
|
|
15692
17813
|
plans: [],
|
|
15693
17814
|
agents: [],
|
|
15694
17815
|
taskLists: [],
|
|
@@ -15700,6 +17821,12 @@ function rowsToSnapshot(rows) {
|
|
|
15700
17821
|
for (const row of rows) {
|
|
15701
17822
|
const payload = payloadRecord(row.payload);
|
|
15702
17823
|
const deletedAt = stringValue(row.deleted_at);
|
|
17824
|
+
if (row.object_type === "machines") {
|
|
17825
|
+
if (deletedAt)
|
|
17826
|
+
throw new Error("Snapshot contains retired machine identities; use the authoritative registry instead of silently discarding tombstones");
|
|
17827
|
+
snapshot.machines.push(payload);
|
|
17828
|
+
continue;
|
|
17829
|
+
}
|
|
15703
17830
|
if (deletedAt) {
|
|
15704
17831
|
snapshot.tombstones ??= [];
|
|
15705
17832
|
snapshot.tombstones.push({
|
|
@@ -15760,12 +17887,12 @@ init_redaction();
|
|
|
15760
17887
|
init_task_parent_integrity();
|
|
15761
17888
|
|
|
15762
17889
|
// src/task-manifest/canonical.ts
|
|
15763
|
-
import { createHash as
|
|
17890
|
+
import { createHash as createHash6 } from "crypto";
|
|
15764
17891
|
function canonicalize2(value) {
|
|
15765
17892
|
if (Array.isArray(value))
|
|
15766
17893
|
return value.map(canonicalize2);
|
|
15767
17894
|
if (value !== null && typeof value === "object") {
|
|
15768
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([
|
|
17895
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key2, entry2]) => [key2, canonicalize2(entry2)]));
|
|
15769
17896
|
}
|
|
15770
17897
|
return value;
|
|
15771
17898
|
}
|
|
@@ -15773,10 +17900,10 @@ function canonicalJson(value) {
|
|
|
15773
17900
|
return JSON.stringify(canonicalize2(value));
|
|
15774
17901
|
}
|
|
15775
17902
|
function canonicalDigest(value) {
|
|
15776
|
-
return
|
|
17903
|
+
return createHash6("sha256").update(canonicalJson(value)).digest("hex");
|
|
15777
17904
|
}
|
|
15778
17905
|
function deterministicUuid(namespace, ...parts) {
|
|
15779
|
-
const bytes =
|
|
17906
|
+
const bytes = createHash6("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
|
|
15780
17907
|
bytes[6] = bytes[6] & 15 | 80;
|
|
15781
17908
|
bytes[8] = bytes[8] & 63 | 128;
|
|
15782
17909
|
const hex = bytes.toString("hex");
|
|
@@ -15803,6 +17930,8 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15803
17930
|
count: (filter = {}) => store.countTasks(filter),
|
|
15804
17931
|
update: (id, input, context) => updateTask2(id, input, store, context),
|
|
15805
17932
|
delete: (id, context) => store.deleteTaskHierarchy(id, context),
|
|
17933
|
+
bulkCreateAtomic: (inputs, context) => bulkCreateTasksAtomic(inputs, store, context),
|
|
17934
|
+
bulkDeleteAtomic: (ids, force, context) => bulkDeleteTasksAtomic(ids, force, store, context),
|
|
15806
17935
|
start: (id, agentId) => startTask2(id, agentId, store),
|
|
15807
17936
|
complete: (id, agentId, options2) => completeTask2(id, agentId, options2, store),
|
|
15808
17937
|
fail: (id, agentId, reason, options2) => failTask2(id, agentId, reason, options2, store),
|
|
@@ -15819,6 +17948,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15819
17948
|
add: (taskId, dependsOn, context) => addDependency2(taskId, dependsOn, store, context),
|
|
15820
17949
|
remove: (taskId, dependsOn) => removeDependency2(taskId, dependsOn, store),
|
|
15821
17950
|
list: (taskId) => listDependencies(taskId, store),
|
|
17951
|
+
listPage: (page) => store.listDependencyPage(page),
|
|
15822
17952
|
listAll: () => store.list("dependencies")
|
|
15823
17953
|
},
|
|
15824
17954
|
verifications: {
|
|
@@ -15836,13 +17966,14 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15836
17966
|
find: (ref) => findGitRefs(ref, store)
|
|
15837
17967
|
},
|
|
15838
17968
|
projects: {
|
|
15839
|
-
create: (input, context) => createProject2(input,
|
|
17969
|
+
create: (input, context) => store.withDependencyGraphTransaction((scoped) => createProject2(input, scoped, context)),
|
|
15840
17970
|
get: (id) => store.get("projects", id),
|
|
15841
17971
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
15842
17972
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
15843
|
-
update: (id, input) => updateProject2(id, input,
|
|
17973
|
+
update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateProject2(id, input, scoped)),
|
|
15844
17974
|
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
15845
|
-
delete: (id, context) => store.
|
|
17975
|
+
delete: async (id, context) => (await store.deleteProjectPreserving(id, false, context)).deleted,
|
|
17976
|
+
deletePreserving: (id, force, context, requireCompletedTasks) => store.deleteProjectPreserving(id, force, context, requireCompletedTasks)
|
|
15846
17977
|
},
|
|
15847
17978
|
plans: {
|
|
15848
17979
|
create: (input, context) => createPlan2(input, store, context),
|
|
@@ -15851,6 +17982,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15851
17982
|
update: (id, input) => updatePlan2(id, input, store),
|
|
15852
17983
|
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
15853
17984
|
delete: (id, context) => store.deletePlan(id, context),
|
|
17985
|
+
deletePreserving: (id, force, context) => store.deletePlanPreserving(id, force, context),
|
|
15854
17986
|
addComment: (input, context) => addPlanComment2(input, store, context),
|
|
15855
17987
|
getComments: async (planId) => {
|
|
15856
17988
|
const pages = [];
|
|
@@ -15875,30 +18007,41 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15875
18007
|
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
15876
18008
|
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
15877
18009
|
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
15878
|
-
getReceiptByIdempotencyKey: (
|
|
18010
|
+
getReceiptByIdempotencyKey: (key2) => store.getPlanProjectLinkReceiptByIdempotencyKey(key2)
|
|
15879
18011
|
},
|
|
15880
18012
|
agents: {
|
|
15881
18013
|
register: (input, context) => registerAgent2(input, store, context),
|
|
15882
18014
|
get: (id) => store.get("agents", id),
|
|
15883
18015
|
getByName: async (name) => matchAgentByName(await store.list("agents"), name),
|
|
15884
18016
|
list: async (options2) => (await store.list("agents")).filter((agent) => options2?.include_archived || agent.status !== "archived").sort((a, b) => a.name.localeCompare(b.name)),
|
|
18017
|
+
listPage: (options2) => store.listAgentPage(options2),
|
|
15885
18018
|
update: (id, input) => updateAgent2(id, input, store),
|
|
15886
18019
|
heartbeat: (idOrName, context) => heartbeatAgent(idOrName, store, context),
|
|
15887
18020
|
release: (idOrName, sessionId, context) => releaseAgent2(idOrName, sessionId, store, context)
|
|
15888
18021
|
},
|
|
15889
18022
|
taskLists: {
|
|
15890
18023
|
create: (input, context) => createTaskList2(input, store, context),
|
|
15891
|
-
get: (id) =>
|
|
15892
|
-
|
|
15893
|
-
|
|
15894
|
-
|
|
15895
|
-
|
|
18024
|
+
get: async (id) => {
|
|
18025
|
+
const list = await store.get("task_lists", id);
|
|
18026
|
+
return list ? withTaskListStatus(list) : null;
|
|
18027
|
+
},
|
|
18028
|
+
getBySlug: async (slug, projectId) => {
|
|
18029
|
+
const list = (await store.list("task_lists")).find((list2) => list2.slug === slug && (projectId === undefined || list2.project_id === projectId));
|
|
18030
|
+
return list ? withTaskListStatus(list) : null;
|
|
18031
|
+
},
|
|
18032
|
+
list: async (projectId) => (await store.list("task_lists")).map(withTaskListStatus).filter((list) => projectId === undefined || list.project_id === projectId).sort((a, b) => a.name.localeCompare(b.name)),
|
|
18033
|
+
update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateTaskList2(id, input, scoped)).then(withTaskListStatus),
|
|
18034
|
+
delete: async (id, context) => (await store.deleteTaskListPreserving(id, false, context)).deleted,
|
|
18035
|
+
deletePreserving: (id, force, context) => store.deleteTaskListPreserving(id, force, context)
|
|
15896
18036
|
},
|
|
15897
18037
|
templates: {
|
|
18038
|
+
updateWithHistory: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
|
|
18039
|
+
history: (id) => store.withTemplateTransaction((scoped) => readTemplateHistory(id, scoped)),
|
|
18040
|
+
initialize: (context) => store.withTemplateTransaction((scoped) => initializeTemplates(scoped, context)),
|
|
15898
18041
|
create: (input, context) => createTemplate2(input, store, context),
|
|
15899
18042
|
get: (id) => store.get("templates", id),
|
|
15900
18043
|
list: async () => (await store.list("templates")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
15901
|
-
update: (id, input) => updateTemplate2(id, input,
|
|
18044
|
+
update: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
|
|
15902
18045
|
delete: (id, context) => deleteTemplate2(id, store, context),
|
|
15903
18046
|
getWithTasks: async (id) => {
|
|
15904
18047
|
const template = await store.get("templates", id);
|
|
@@ -15929,13 +18072,16 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15929
18072
|
getCommentsPage: async (taskId, options2) => {
|
|
15930
18073
|
return (await store.listComments(taskId, options2)).map(redactComment).sort((a, b) => a.created_at.localeCompare(b.created_at) || a.id.localeCompare(b.id));
|
|
15931
18074
|
},
|
|
15932
|
-
getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) =>
|
|
15933
|
-
|
|
18075
|
+
getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)),
|
|
18076
|
+
getTaskHistoryPage: (taskId, options2) => store.listTaskHistoryPage(taskId, options2),
|
|
18077
|
+
getRecentActivity: (limit = 20) => store.listRecentActivity(limit)
|
|
15934
18078
|
},
|
|
18079
|
+
machines: createPostgresMachineRegistry(options.client, options.service ?? "todos", options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, () => store.ensureSchema()),
|
|
18080
|
+
atomicProjectMigration: createAtomicProjectMigration({ client: options.client, table: options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, service: options.service ?? "todos", ensureSchema: () => store.ensureSchema() }),
|
|
15935
18081
|
sync: {
|
|
15936
18082
|
getTasksChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
15937
18083
|
exportSnapshot: () => exportSnapshot(store),
|
|
15938
|
-
importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
|
|
18084
|
+
importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context, adapter.machines)
|
|
15939
18085
|
},
|
|
15940
18086
|
integrity: {
|
|
15941
18087
|
report: () => store.integrityReport()
|
|
@@ -15952,6 +18098,7 @@ class PostgresJsonRecordStore {
|
|
|
15952
18098
|
tableName;
|
|
15953
18099
|
cursorTableName;
|
|
15954
18100
|
schemaReady = null;
|
|
18101
|
+
projectIntegrityLocked = false;
|
|
15955
18102
|
constructor(options) {
|
|
15956
18103
|
this.options = options;
|
|
15957
18104
|
this.service = options.service ?? "todos";
|
|
@@ -15962,6 +18109,45 @@ class PostgresJsonRecordStore {
|
|
|
15962
18109
|
machineId(context) {
|
|
15963
18110
|
return context?.requestId ?? this.sourceMachineId ?? null;
|
|
15964
18111
|
}
|
|
18112
|
+
async withDependencyGraphTransaction(fn) {
|
|
18113
|
+
await this.ensureSchema();
|
|
18114
|
+
if (this.projectIntegrityLocked)
|
|
18115
|
+
return fn(this);
|
|
18116
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
18117
|
+
const scoped = new PostgresJsonRecordStore({ ...this.options, client });
|
|
18118
|
+
scoped.schemaReady = Promise.resolve();
|
|
18119
|
+
scoped.projectIntegrityLocked = true;
|
|
18120
|
+
return fn(scoped);
|
|
18121
|
+
});
|
|
18122
|
+
}
|
|
18123
|
+
async withTemplateTransaction(fn) {
|
|
18124
|
+
await this.ensureSchema();
|
|
18125
|
+
const transaction = this.options.client.transaction;
|
|
18126
|
+
if (!transaction)
|
|
18127
|
+
throw new Error("TEMPLATE_ATOMICITY_UNAVAILABLE: upgrade the Todos storage backend");
|
|
18128
|
+
return transaction(async (client) => {
|
|
18129
|
+
await client.query("/* todos:template-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':template-integrity', 0))", [this.service]);
|
|
18130
|
+
const scoped = new PostgresJsonRecordStore({ ...this.options, client });
|
|
18131
|
+
scoped.schemaReady = Promise.resolve();
|
|
18132
|
+
return fn(scoped);
|
|
18133
|
+
});
|
|
18134
|
+
}
|
|
18135
|
+
async lockTemplate(id) {
|
|
18136
|
+
await this.options.client.query(`/* todos:template-row-lock */ SELECT object_id FROM ${this.tableName} WHERE service = $1 AND object_type = 'templates' AND object_id = $2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
|
|
18137
|
+
}
|
|
18138
|
+
async persistTemplateRevision(template, previousVersion) {
|
|
18139
|
+
const result = await this.options.client.query(`/* todos:template-version-cas */ UPDATE ${this.tableName}
|
|
18140
|
+
SET payload = $3::jsonb,
|
|
18141
|
+
updated_at = GREATEST(clock_timestamp(), COALESCE(updated_at, 'epoch'::timestamptz) + interval '1 microsecond'),
|
|
18142
|
+
version = $5
|
|
18143
|
+
WHERE service = $1 AND object_type = 'templates' AND object_id = $2
|
|
18144
|
+
AND deleted_at IS NULL AND (payload->>'version')::integer = $4
|
|
18145
|
+
AND COALESCE(version, 1) = $4
|
|
18146
|
+
RETURNING payload`, [this.service, template.id, jsonbParam(template), previousVersion, template.version]);
|
|
18147
|
+
if (result.rows.length !== 1)
|
|
18148
|
+
throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template revision write was refused; no history was committed");
|
|
18149
|
+
return payloadRecord2(result.rows[0].payload);
|
|
18150
|
+
}
|
|
15965
18151
|
async ensureSchema() {
|
|
15966
18152
|
if (!this.schemaReady) {
|
|
15967
18153
|
this.schemaReady = (async () => {
|
|
@@ -16073,6 +18259,128 @@ class PostgresJsonRecordStore {
|
|
|
16073
18259
|
async list(type) {
|
|
16074
18260
|
return (await this.listRecords(type)).map((record) => record.payload);
|
|
16075
18261
|
}
|
|
18262
|
+
async listAgentPage(options) {
|
|
18263
|
+
const { limit, offset } = options;
|
|
18264
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
|
18265
|
+
throw new Error("Postgres agent page limit must be an integer from 1 to 500");
|
|
18266
|
+
}
|
|
18267
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
18268
|
+
throw new Error("Postgres agent page offset must be a non-negative integer");
|
|
18269
|
+
}
|
|
18270
|
+
await this.ensureSchema();
|
|
18271
|
+
const result = await this.options.client.query(`/* todos:list-agent-page */ WITH filtered AS (
|
|
18272
|
+
SELECT object_id, payload
|
|
18273
|
+
FROM ${this.tableName}
|
|
18274
|
+
WHERE service = $1 AND object_type = 'agents' AND deleted_at IS NULL
|
|
18275
|
+
AND ($2::boolean OR COALESCE(payload->>'status', 'active') <> 'archived')
|
|
18276
|
+
), page AS (
|
|
18277
|
+
SELECT object_id, payload FROM filtered
|
|
18278
|
+
ORDER BY LOWER(payload->>'name'), object_id
|
|
18279
|
+
LIMIT $3 OFFSET $4
|
|
18280
|
+
)
|
|
18281
|
+
SELECT (SELECT count(*) FROM filtered) AS total,
|
|
18282
|
+
COALESCE((SELECT jsonb_agg(payload ORDER BY LOWER(payload->>'name'), object_id) FROM page), '[]'::jsonb) AS agents`, [this.service, options.include_archived === true, limit, offset]);
|
|
18283
|
+
const row = result.rows[0];
|
|
18284
|
+
const total = Number(row?.total);
|
|
18285
|
+
if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.agents)) {
|
|
18286
|
+
throw new Error("Postgres agent page returned invalid count or agents");
|
|
18287
|
+
}
|
|
18288
|
+
if (row.agents.length > limit || row.agents.length > 0 && offset + row.agents.length > total) {
|
|
18289
|
+
throw new Error("Postgres agent page exceeded its requested bounds");
|
|
18290
|
+
}
|
|
18291
|
+
return { agents: row.agents.map((value) => payloadRecord2(value)), total };
|
|
18292
|
+
}
|
|
18293
|
+
async listRecentActivity(limit) {
|
|
18294
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1e4) {
|
|
18295
|
+
throw new Error("Postgres recent activity limit must be an integer from 1 to 10000");
|
|
18296
|
+
}
|
|
18297
|
+
await this.ensureSchema();
|
|
18298
|
+
const result = await this.options.client.query(`/* todos:list-recent-activity */ SELECT payload
|
|
18299
|
+
FROM ${this.tableName}
|
|
18300
|
+
WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
|
|
18301
|
+
ORDER BY payload->>'created_at' DESC, object_id DESC
|
|
18302
|
+
LIMIT $2`, [this.service, limit]);
|
|
18303
|
+
return result.rows.map((row) => payloadRecord2(row.payload));
|
|
18304
|
+
}
|
|
18305
|
+
async listTaskHistoryPage(taskId, options) {
|
|
18306
|
+
const { limit, offset } = options;
|
|
18307
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
|
18308
|
+
throw new Error("Postgres task history limit must be an integer from 1 to 500");
|
|
18309
|
+
}
|
|
18310
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
18311
|
+
throw new Error("Postgres task history offset must be a non-negative integer");
|
|
18312
|
+
}
|
|
18313
|
+
await this.ensureSchema();
|
|
18314
|
+
const direction = options.order === "asc" ? "ASC" : "DESC";
|
|
18315
|
+
const result = await this.options.client.query(`/* todos:list-task-history-page */ WITH filtered AS (
|
|
18316
|
+
SELECT object_id, payload, payload->>'created_at' AS created_at
|
|
18317
|
+
FROM ${this.tableName}
|
|
18318
|
+
WHERE service = $1 AND object_type = 'audit_history' AND deleted_at IS NULL
|
|
18319
|
+
AND payload->>'task_id' = $2
|
|
18320
|
+
AND ($3::text IS NULL OR payload->>'created_at' >= $3)
|
|
18321
|
+
AND ($4::text IS NULL OR payload->>'created_at' <= $4)
|
|
18322
|
+
), page AS (
|
|
18323
|
+
SELECT object_id, payload, created_at FROM filtered
|
|
18324
|
+
ORDER BY created_at ${direction}, object_id ${direction}
|
|
18325
|
+
LIMIT $5 OFFSET $6
|
|
18326
|
+
)
|
|
18327
|
+
SELECT (SELECT count(*) FROM filtered) AS total,
|
|
18328
|
+
COALESCE((SELECT jsonb_agg(payload ORDER BY created_at ${direction}, object_id ${direction}) FROM page), '[]'::jsonb) AS history`, [this.service, taskId, options.since ?? null, options.until ?? null, limit, offset]);
|
|
18329
|
+
const row = result.rows[0];
|
|
18330
|
+
const total = Number(row?.total);
|
|
18331
|
+
if (!Number.isSafeInteger(total) || total < 0 || !Array.isArray(row?.history)) {
|
|
18332
|
+
throw new Error("Postgres task history page returned invalid count or history");
|
|
18333
|
+
}
|
|
18334
|
+
if (row.history.length > limit || row.history.length > 0 && offset + row.history.length > total) {
|
|
18335
|
+
throw new Error("Postgres task history page exceeded its requested bounds");
|
|
18336
|
+
}
|
|
18337
|
+
return { history: row.history.map((value) => payloadRecord2(value)), total };
|
|
18338
|
+
}
|
|
18339
|
+
async listDependencyPage(options) {
|
|
18340
|
+
const { limit, offset } = options;
|
|
18341
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500) {
|
|
18342
|
+
throw new Error("Postgres dependency page limit must be an integer from 1 to 500");
|
|
18343
|
+
}
|
|
18344
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
18345
|
+
throw new Error("Postgres dependency page offset must be a non-negative integer");
|
|
18346
|
+
}
|
|
18347
|
+
await this.ensureSchema();
|
|
18348
|
+
const result = await this.options.client.query(`/* todos:list-dependencies-page */ WITH page AS (
|
|
18349
|
+
SELECT payload, updated_at, object_id
|
|
18350
|
+
FROM ${this.tableName}
|
|
18351
|
+
WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL
|
|
18352
|
+
ORDER BY updated_at ASC, object_id ASC
|
|
18353
|
+
LIMIT $2 OFFSET $3
|
|
18354
|
+
)
|
|
18355
|
+
SELECT
|
|
18356
|
+
(SELECT COUNT(*)::text FROM ${this.tableName}
|
|
18357
|
+
WHERE service = $1 AND object_type = 'dependencies' AND deleted_at IS NULL) AS total,
|
|
18358
|
+
COALESCE(
|
|
18359
|
+
jsonb_agg(page.payload ORDER BY page.updated_at ASC, page.object_id ASC)
|
|
18360
|
+
FILTER (WHERE page.payload IS NOT NULL),
|
|
18361
|
+
'[]'::jsonb
|
|
18362
|
+
) AS dependencies
|
|
18363
|
+
FROM page`, [this.service, limit, offset]);
|
|
18364
|
+
const row = result.rows[0];
|
|
18365
|
+
const totalText = typeof row?.total === "string" ? row.total : typeof row?.total === "number" && Number.isSafeInteger(row.total) ? String(row.total) : null;
|
|
18366
|
+
if (totalText === null || !/^\d+$/.test(totalText)) {
|
|
18367
|
+
throw new Error("Postgres dependency page returned an invalid total");
|
|
18368
|
+
}
|
|
18369
|
+
const total = Number(totalText);
|
|
18370
|
+
if (!Number.isSafeInteger(total)) {
|
|
18371
|
+
throw new Error("Postgres dependency page total exceeds the safe integer range");
|
|
18372
|
+
}
|
|
18373
|
+
if (!Array.isArray(row?.dependencies)) {
|
|
18374
|
+
throw new Error("Postgres dependency page returned an invalid dependencies array");
|
|
18375
|
+
}
|
|
18376
|
+
if (row.dependencies.length > limit || offset + row.dependencies.length > total) {
|
|
18377
|
+
throw new Error("Postgres dependency page returned rows outside the requested bound");
|
|
18378
|
+
}
|
|
18379
|
+
return {
|
|
18380
|
+
dependencies: row.dependencies.map((value) => payloadRecord2(value)),
|
|
18381
|
+
total
|
|
18382
|
+
};
|
|
18383
|
+
}
|
|
16076
18384
|
async listComments(taskId, options = {}) {
|
|
16077
18385
|
await this.ensureSchema();
|
|
16078
18386
|
const limit = options.limit ?? 100;
|
|
@@ -16148,6 +18456,10 @@ class PostgresJsonRecordStore {
|
|
|
16148
18456
|
return "1=0";
|
|
16149
18457
|
return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
|
|
16150
18458
|
};
|
|
18459
|
+
if (filter.archived_only === true)
|
|
18460
|
+
conds.push(`(payload->>'archived_at' IS NOT NULL)`);
|
|
18461
|
+
else if (filter.include_archived === false)
|
|
18462
|
+
conds.push(`(payload->>'archived_at' IS NULL)`);
|
|
16151
18463
|
if (filter.ids)
|
|
16152
18464
|
conds.push(inClause("payload->>'id'", filter.ids));
|
|
16153
18465
|
if (filter.project_id !== undefined)
|
|
@@ -16203,7 +18515,7 @@ class PostgresJsonRecordStore {
|
|
|
16203
18515
|
async listTasks(filter) {
|
|
16204
18516
|
await this.ensureSchema();
|
|
16205
18517
|
const { where, params, queryRef } = await this.buildTaskFilterSql(filter);
|
|
16206
|
-
const orderBy = queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
|
|
18518
|
+
const orderBy = filter.archived_only ? `ORDER BY payload->>'archived_at' DESC, object_id DESC` : queryRef ? `ORDER BY ts_rank_cd(task_search_tsv, websearch_to_tsquery('simple', todos_immutable_unaccent(${queryRef}))) DESC, ${TASK_ORDER_TIEBREAK}` : TASK_ORDER_BY;
|
|
16207
18519
|
let sql = `/* todos:list-tasks */ SELECT payload FROM ${this.tableName} WHERE ${where} ${orderBy}`;
|
|
16208
18520
|
if (filter.limit !== undefined) {
|
|
16209
18521
|
params.push(filter.limit);
|
|
@@ -16316,6 +18628,32 @@ class PostgresJsonRecordStore {
|
|
|
16316
18628
|
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
16317
18629
|
}
|
|
16318
18630
|
}
|
|
18631
|
+
const projectTypes = new Set(["projects", "tasks", "plans", "task_lists"]);
|
|
18632
|
+
const reference = type === "projects" ? value.parent_id : value.project_id;
|
|
18633
|
+
const planReference = value.plan_id;
|
|
18634
|
+
const needsPlanFence = type === "tasks" && typeof planReference === "string" && planReference.length > 0;
|
|
18635
|
+
const listReference = value.task_list_id;
|
|
18636
|
+
const needsListFence = (type === "tasks" || type === "plans") && typeof listReference === "string" && listReference.length > 0;
|
|
18637
|
+
const needsProjectFence = needsListFence || needsPlanFence || type === "projects" || projectTypes.has(type) && typeof reference === "string" && reference.length > 0;
|
|
18638
|
+
if (needsProjectFence && !this.projectIntegrityLocked) {
|
|
18639
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.upsert(type, value, context));
|
|
18640
|
+
}
|
|
18641
|
+
if (needsListFence)
|
|
18642
|
+
await this.assertTaskListMembership(type, value.id, listReference);
|
|
18643
|
+
if (needsPlanFence) {
|
|
18644
|
+
const plan = await this.options.client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, planReference]);
|
|
18645
|
+
if (!plan.rows.length)
|
|
18646
|
+
throw new PlanNotFoundError(planReference);
|
|
18647
|
+
}
|
|
18648
|
+
if (projectTypes.has(type)) {
|
|
18649
|
+
if (typeof reference === "string" && reference)
|
|
18650
|
+
await this.assertLiveProject(reference);
|
|
18651
|
+
if (type === "projects") {
|
|
18652
|
+
const prior = await this.options.client.query(`SELECT deleted_at FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 FOR UPDATE`, [this.service, value.id]);
|
|
18653
|
+
if (prior.rows[0]?.deleted_at)
|
|
18654
|
+
throw new ProjectNotFoundError(value.id);
|
|
18655
|
+
}
|
|
18656
|
+
}
|
|
16319
18657
|
await this.ensureSchema();
|
|
16320
18658
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
16321
18659
|
let result;
|
|
@@ -16397,10 +18735,15 @@ class PostgresJsonRecordStore {
|
|
|
16397
18735
|
if (planIds.length === 0 && !parentGuard)
|
|
16398
18736
|
return this.upsert("tasks", value, context);
|
|
16399
18737
|
await this.ensureSchema();
|
|
16400
|
-
if (
|
|
18738
|
+
if (!queryClient && this.projectIntegrityLocked)
|
|
18739
|
+
queryClient = this.options.client;
|
|
18740
|
+
if (!queryClient) {
|
|
16401
18741
|
return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
|
|
16402
18742
|
}
|
|
16403
18743
|
const client = queryClient ?? this.options.client;
|
|
18744
|
+
if (value.project_id)
|
|
18745
|
+
await this.assertLiveProject(value.project_id, client);
|
|
18746
|
+
await this.assertTaskListMembership("tasks", value.id, value.task_list_id, client);
|
|
16404
18747
|
const updatedAt = value.updated_at;
|
|
16405
18748
|
const targetPlanId = value.plan_id;
|
|
16406
18749
|
const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
|
|
@@ -16535,8 +18878,11 @@ class PostgresJsonRecordStore {
|
|
|
16535
18878
|
}
|
|
16536
18879
|
return payloadRecord2(row.payload);
|
|
16537
18880
|
}
|
|
16538
|
-
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
18881
|
+
async updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context = {}) {
|
|
18882
|
+
if (!this.projectIntegrityLocked)
|
|
18883
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context));
|
|
16539
18884
|
await this.ensureSchema();
|
|
18885
|
+
await this.assertTaskListMembership("plans", value.id, value.task_list_id);
|
|
16540
18886
|
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
16541
18887
|
SELECT payload FROM ${this.tableName}
|
|
16542
18888
|
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
@@ -16555,19 +18901,24 @@ class PostgresJsonRecordStore {
|
|
|
16555
18901
|
version = COALESCE(r.version, 0) + 1
|
|
16556
18902
|
FROM locked_plan
|
|
16557
18903
|
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
18904
|
+
AND locked_plan.payload->>'updated_at' = $6
|
|
16558
18905
|
RETURNING r.payload
|
|
16559
18906
|
)
|
|
16560
18907
|
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
18908
|
+
(SELECT payload->>'updated_at' FROM locked_plan) AS current_revision,
|
|
16561
18909
|
(SELECT payload FROM stored) AS payload`, [
|
|
16562
18910
|
this.service,
|
|
16563
18911
|
value.id,
|
|
16564
18912
|
jsonbParam(value),
|
|
16565
18913
|
value.updated_at,
|
|
16566
|
-
context.requestId ?? this.sourceMachineId ?? null
|
|
18914
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
18915
|
+
expectedUpdatedAt
|
|
16567
18916
|
]);
|
|
16568
18917
|
const row = result.rows[0];
|
|
16569
|
-
if (!row?.plan_found
|
|
18918
|
+
if (!row?.plan_found)
|
|
16570
18919
|
throw new PlanNotFoundError(value.id);
|
|
18920
|
+
if (!row.payload)
|
|
18921
|
+
throw new PlanRevisionConflictError(value.id, expectedUpdatedAt, row.current_revision);
|
|
16571
18922
|
return payloadRecord2(row.payload);
|
|
16572
18923
|
}
|
|
16573
18924
|
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
@@ -16841,6 +19192,71 @@ class PostgresJsonRecordStore {
|
|
|
16841
19192
|
return null;
|
|
16842
19193
|
return Number(row.counter);
|
|
16843
19194
|
}
|
|
19195
|
+
async assertTaskListMembership(type, id, target, client = this.options.client) {
|
|
19196
|
+
if (target === undefined || target === null || target === "")
|
|
19197
|
+
return;
|
|
19198
|
+
const prior = await client.query(`SELECT payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type=$2 AND object_id=$3 AND deleted_at IS NULL`, [this.service, type, id]);
|
|
19199
|
+
if (prior.rows[0]?.task_list_id === target)
|
|
19200
|
+
return;
|
|
19201
|
+
const list = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, target]);
|
|
19202
|
+
if (!list.rows.length)
|
|
19203
|
+
throw new TaskListNotFoundError(String(target));
|
|
19204
|
+
}
|
|
19205
|
+
async deleteTaskListPreserving(id, force, context = {}) {
|
|
19206
|
+
await this.ensureSchema();
|
|
19207
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
19208
|
+
const receipt = { schema_version: 1, task_list_id: id, deleted: false, detached_task_ids: [], detached_plan_ids: [], detached_tasks: 0, detached_plans: 0 };
|
|
19209
|
+
const target = await client.query(`SELECT object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
|
|
19210
|
+
if (!target.rows.length)
|
|
19211
|
+
return receipt;
|
|
19212
|
+
const related = await client.query(`SELECT object_type,object_id,updated_at FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2 ORDER BY object_type,object_id FOR UPDATE`, [this.service, id]);
|
|
19213
|
+
if (related.rows.length && !force)
|
|
19214
|
+
throw new ResourceConflictError("TASK_LIST_NOT_EMPTY", "Task list has linked records; force confirms detaching them without deleting content or history");
|
|
19215
|
+
const timestamp2 = new Date([...target.rows, ...related.rows].reduce((latest, row) => Math.max(latest, Date.parse(String(row.updated_at)) + 1), Date.now())).toISOString();
|
|
19216
|
+
await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{task_list_id}','null'::jsonb)||jsonb_build_object('updated_at',$3::text)||CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type IN ('tasks','plans') AND payload->>'task_list_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
|
|
19217
|
+
await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='task_lists' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
|
|
19218
|
+
receipt.deleted = true;
|
|
19219
|
+
receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
|
|
19220
|
+
receipt.detached_plan_ids = related.rows.filter((row) => row.object_type === "plans").map((row) => row.object_id);
|
|
19221
|
+
receipt.detached_tasks = receipt.detached_task_ids.length;
|
|
19222
|
+
receipt.detached_plans = receipt.detached_plan_ids.length;
|
|
19223
|
+
return receipt;
|
|
19224
|
+
});
|
|
19225
|
+
}
|
|
19226
|
+
async assertLiveProject(id, client = this.options.client) {
|
|
19227
|
+
const result = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR KEY SHARE`, [this.service, id]);
|
|
19228
|
+
if (!result.rows.length)
|
|
19229
|
+
throw new ProjectNotFoundError(id);
|
|
19230
|
+
}
|
|
19231
|
+
async deleteProjectPreserving(id, force, context = {}, requireCompletedTasks = false) {
|
|
19232
|
+
await this.ensureSchema();
|
|
19233
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
19234
|
+
const project = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
|
|
19235
|
+
const receipt = { schema_version: 1, project_id: id, deleted: false, preserved_tasks: 0, preserved_plans: 0, detached_task_lists: 0, detached_child_projects: 0 };
|
|
19236
|
+
if (!project.rows.length)
|
|
19237
|
+
return receipt;
|
|
19238
|
+
const related = await client.query(`SELECT object_type,object_id,payload->>'status' AS status FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2)) FOR UPDATE`, [this.service, id]);
|
|
19239
|
+
if (requireCompletedTasks && related.rows.some((row) => row.object_type === "tasks" && row.status !== "completed" && row.status !== "cancelled"))
|
|
19240
|
+
throw new ResourceConflictError("PROJECT_INCOMPLETE", "Project has incomplete tasks; deregistration refused");
|
|
19241
|
+
if (related.rows.length && !force)
|
|
19242
|
+
throw new ResourceConflictError("PROJECT_NOT_EMPTY", "Project has linked records; force confirms detaching them without deleting their content");
|
|
19243
|
+
const timestamp2 = new Date().toISOString();
|
|
19244
|
+
await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(jsonb_set(payload,CASE WHEN object_type='projects' THEN '{parent_id}'::text[] ELSE '{project_id}'::text[] END,'null'::jsonb),'{updated_at}',to_jsonb($3::text)) || CASE WHEN object_type='tasks' THEN jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1) ELSE '{}'::jsonb END, updated_at=$3::timestamptz, version=CASE WHEN object_type='tasks' THEN COALESCE(version,0)+1 ELSE version END,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND ((object_type IN ('tasks','plans','task_lists') AND payload->>'project_id'=$2) OR (object_type='projects' AND payload->>'parent_id'=$2))`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
|
|
19245
|
+
await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,source_machine_id=$4 WHERE service=$1 AND object_type='projects' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, context.requestId ?? this.sourceMachineId ?? null]);
|
|
19246
|
+
receipt.deleted = true;
|
|
19247
|
+
for (const row of related.rows) {
|
|
19248
|
+
if (row.object_type === "tasks")
|
|
19249
|
+
receipt.preserved_tasks++;
|
|
19250
|
+
else if (row.object_type === "plans")
|
|
19251
|
+
receipt.preserved_plans++;
|
|
19252
|
+
else if (row.object_type === "task_lists")
|
|
19253
|
+
receipt.detached_task_lists++;
|
|
19254
|
+
else
|
|
19255
|
+
receipt.detached_child_projects++;
|
|
19256
|
+
}
|
|
19257
|
+
return receipt;
|
|
19258
|
+
});
|
|
19259
|
+
}
|
|
16844
19260
|
async delete(type, id, context = {}) {
|
|
16845
19261
|
await this.ensureSchema();
|
|
16846
19262
|
const existing = await this.get(type, id);
|
|
@@ -16858,9 +19274,11 @@ class PostgresJsonRecordStore {
|
|
|
16858
19274
|
}
|
|
16859
19275
|
async deleteTaskHierarchy(id, context = {}) {
|
|
16860
19276
|
await this.ensureSchema();
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
19277
|
+
if (!this.projectIntegrityLocked) {
|
|
19278
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.deleteTaskHierarchy(id, context));
|
|
19279
|
+
}
|
|
19280
|
+
const timestamp2 = new Date().toISOString();
|
|
19281
|
+
const result = await this.options.client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
|
|
16864
19282
|
task_tree(object_id, path, cycle) AS (
|
|
16865
19283
|
SELECT task.object_id, ARRAY[task.object_id], false
|
|
16866
19284
|
FROM ${this.tableName} AS task
|
|
@@ -16922,13 +19340,12 @@ class PostgresJsonRecordStore {
|
|
|
16922
19340
|
SELECT EXISTS (SELECT 1 FROM task_tree WHERE object_id = $2) AS found,
|
|
16923
19341
|
(SELECT count(*) FROM tombstoned) AS deleted_count,
|
|
16924
19342
|
(SELECT count(*) FROM tombstoned_related) AS related_deleted_count`, [
|
|
16925
|
-
|
|
16926
|
-
|
|
16927
|
-
|
|
16928
|
-
|
|
16929
|
-
|
|
16930
|
-
|
|
16931
|
-
});
|
|
19343
|
+
this.service,
|
|
19344
|
+
id,
|
|
19345
|
+
timestamp2,
|
|
19346
|
+
context.requestId ?? this.sourceMachineId ?? null
|
|
19347
|
+
]);
|
|
19348
|
+
return Boolean(result.rows[0]?.found);
|
|
16932
19349
|
}
|
|
16933
19350
|
async getPlanProjectLinkReceipt(receiptId) {
|
|
16934
19351
|
const value = await this.get("plan_project_link_receipts", receiptId);
|
|
@@ -16945,7 +19362,7 @@ class PostgresJsonRecordStore {
|
|
|
16945
19362
|
}
|
|
16946
19363
|
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
16947
19364
|
}
|
|
16948
|
-
async currentPlanProjectLinkResult(receipt, action) {
|
|
19365
|
+
async currentPlanProjectLinkResult(receipt, action, previouslyCommitted = false) {
|
|
16949
19366
|
const [plan, project, tasks] = await Promise.all([
|
|
16950
19367
|
this.get("plans", receipt.plan_id),
|
|
16951
19368
|
this.get("projects", receipt.project_id),
|
|
@@ -16953,11 +19370,14 @@ class PostgresJsonRecordStore {
|
|
|
16953
19370
|
]);
|
|
16954
19371
|
const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
16955
19372
|
if (!plan || !project || planProjectLinkResultDigest(plan, sortedTasks2) !== receipt.result_digest) {
|
|
16956
|
-
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id });
|
|
19373
|
+
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_RESULT_DRIFT", previouslyCommitted ? "The plan-project link was already committed; current state has drifted from its immutable receipt" : "The accepted plan-project-link result has drifted", { receipt_id: receipt.receipt_id, ...previouslyCommitted ? { operation_committed: true, current_state_matches_receipt: false, receipt } : {} });
|
|
16957
19374
|
}
|
|
16958
19375
|
return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
|
|
16959
19376
|
}
|
|
16960
19377
|
async applyPlanProjectLink(input, context = {}) {
|
|
19378
|
+
if (!this.projectIntegrityLocked) {
|
|
19379
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.applyPlanProjectLink(input, context));
|
|
19380
|
+
}
|
|
16961
19381
|
await this.ensureSchema();
|
|
16962
19382
|
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
16963
19383
|
if (existing) {
|
|
@@ -16968,7 +19388,7 @@ class PostgresJsonRecordStore {
|
|
|
16968
19388
|
if (rolledBack) {
|
|
16969
19389
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
16970
19390
|
}
|
|
16971
|
-
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
19391
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked", true);
|
|
16972
19392
|
}
|
|
16973
19393
|
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
16974
19394
|
this.get("plans", input.plan_id),
|
|
@@ -17110,7 +19530,7 @@ class PostgresJsonRecordStore {
|
|
|
17110
19530
|
if (isPostgresUniqueViolation(error)) {
|
|
17111
19531
|
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
17112
19532
|
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
17113
|
-
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
19533
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked", true);
|
|
17114
19534
|
}
|
|
17115
19535
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
17116
19536
|
}
|
|
@@ -17133,7 +19553,7 @@ class PostgresJsonRecordStore {
|
|
|
17133
19553
|
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
17134
19554
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
17135
19555
|
}
|
|
17136
|
-
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
19556
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked", row.existing_receipt != null);
|
|
17137
19557
|
}
|
|
17138
19558
|
async rollbackPlanProjectLink(input, context = {}) {
|
|
17139
19559
|
await this.ensureSchema();
|
|
@@ -17256,6 +19676,28 @@ class PostgresJsonRecordStore {
|
|
|
17256
19676
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
17257
19677
|
return accepted;
|
|
17258
19678
|
}
|
|
19679
|
+
async deletePlanPreserving(id, force, context = {}) {
|
|
19680
|
+
await this.ensureSchema();
|
|
19681
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
19682
|
+
const receipt = { schema_version: 1, plan_id: id, deleted: false, detached_task_ids: [], detached_task_list_ids: [], detached_tasks: 0, detached_task_lists: 0 };
|
|
19683
|
+
const target = await client.query(`SELECT object_id,payload->>'task_list_id' AS task_list_id FROM ${this.tableName} WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL FOR UPDATE`, [this.service, id]);
|
|
19684
|
+
if (!target.rows.length)
|
|
19685
|
+
return receipt;
|
|
19686
|
+
const related = await client.query(`SELECT object_type,object_id FROM ${this.tableName} WHERE service=$1 AND deleted_at IS NULL AND ((object_type='tasks' AND payload->>'plan_id'=$2) OR (object_type='task_lists' AND object_id=$3)) ORDER BY object_type,object_id FOR UPDATE`, [this.service, id, target.rows[0].task_list_id]);
|
|
19687
|
+
const notes = await client.query(`SELECT object_id FROM ${this.tableName} WHERE service=$1 AND object_type='plan_comments' AND payload->>'plan_id'=$2 AND deleted_at IS NULL LIMIT 1`, [this.service, id]);
|
|
19688
|
+
if ((related.rows.length || notes.rows.length) && !force)
|
|
19689
|
+
throw new ResourceConflictError("PLAN_NOT_EMPTY", "Plan has linked records; force confirms detaching them while preserving content and history");
|
|
19690
|
+
const timestamp2 = new Date().toISOString();
|
|
19691
|
+
await client.query(`UPDATE ${this.tableName} SET payload=jsonb_set(payload,'{plan_id}','null'::jsonb) || jsonb_build_object('updated_at',$3::text) || jsonb_build_object('version',COALESCE((payload->>'version')::integer,0)+1),updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND deleted_at IS NULL AND object_type='tasks' AND payload->>'plan_id'=$2`, [this.service, id, timestamp2, this.machineId(context)]);
|
|
19692
|
+
await client.query(`UPDATE ${this.tableName} SET deleted_at=$3::timestamptz,updated_at=$3::timestamptz,version=COALESCE(version,0)+1,source_machine_id=$4 WHERE service=$1 AND object_type='plans' AND object_id=$2 AND deleted_at IS NULL`, [this.service, id, timestamp2, this.machineId(context)]);
|
|
19693
|
+
receipt.deleted = true;
|
|
19694
|
+
receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
|
|
19695
|
+
receipt.detached_task_list_ids = related.rows.filter((row) => row.object_type === "task_lists").map((row) => row.object_id);
|
|
19696
|
+
receipt.detached_tasks = receipt.detached_task_ids.length;
|
|
19697
|
+
receipt.detached_task_lists = receipt.detached_task_list_ids.length;
|
|
19698
|
+
return receipt;
|
|
19699
|
+
});
|
|
19700
|
+
}
|
|
17259
19701
|
async deletePlan(id, context = {}) {
|
|
17260
19702
|
await this.ensureSchema();
|
|
17261
19703
|
const timestamp2 = new Date().toISOString();
|
|
@@ -17302,6 +19744,8 @@ class PostgresJsonRecordStore {
|
|
|
17302
19744
|
}
|
|
17303
19745
|
async tombstone(tombstone, context = {}) {
|
|
17304
19746
|
await this.ensureSchema();
|
|
19747
|
+
if (tombstone.object_type === "atomic_project_migrations")
|
|
19748
|
+
throw new Error("Migration evidence is immutable");
|
|
17305
19749
|
const deletedAt = stringValue2(tombstone.deleted_at) ?? new Date().toISOString();
|
|
17306
19750
|
const updatedAt = stringValue2(tombstone.updated_at) ?? deletedAt;
|
|
17307
19751
|
const existing = await this.clock(tombstone.object_type, tombstone.object_id);
|
|
@@ -17357,9 +19801,105 @@ class PostgresJsonRecordStore {
|
|
|
17357
19801
|
updated_at = EXCLUDED.updated_at`, [this.service, name, value]);
|
|
17358
19802
|
}
|
|
17359
19803
|
}
|
|
19804
|
+
async function bulkCreateTasksAtomic(inputs, store, context) {
|
|
19805
|
+
return store.withDependencyGraphTransaction(async (scoped) => {
|
|
19806
|
+
const tempIds = new Map;
|
|
19807
|
+
const created = [];
|
|
19808
|
+
const dependencies = [];
|
|
19809
|
+
for (const input of inputs) {
|
|
19810
|
+
const { temp_id, depends_on: _dependsOn, ...taskInput } = input;
|
|
19811
|
+
const task = await createTask2(taskInput, scoped, context);
|
|
19812
|
+
if (temp_id)
|
|
19813
|
+
tempIds.set(temp_id, task.id);
|
|
19814
|
+
created.push({ temp_id: temp_id ?? null, id: task.id, short_id: task.short_id, title: task.title });
|
|
19815
|
+
}
|
|
19816
|
+
for (let index = 0;index < inputs.length; index++) {
|
|
19817
|
+
const input = inputs[index];
|
|
19818
|
+
const taskId = created[index].id;
|
|
19819
|
+
const seenDependencies = new Set;
|
|
19820
|
+
for (const reference of input.depends_on ?? []) {
|
|
19821
|
+
const exact = tempIds.get(reference) ?? (await scoped.get("tasks", reference))?.id;
|
|
19822
|
+
const dependencyId = exact ?? (await scoped.resolveTaskRef(reference))?.id;
|
|
19823
|
+
if (!dependencyId)
|
|
19824
|
+
throw new TaskNotFoundError(reference);
|
|
19825
|
+
if (seenDependencies.has(dependencyId)) {
|
|
19826
|
+
throw new ResourceConflictError("BULK_CREATE_DUPLICATE_DEPENDENCY", `Multiple references resolve to dependency ${dependencyId}`);
|
|
19827
|
+
}
|
|
19828
|
+
seenDependencies.add(dependencyId);
|
|
19829
|
+
dependencies.push(await addDependency2(taskId, dependencyId, scoped, context));
|
|
19830
|
+
}
|
|
19831
|
+
}
|
|
19832
|
+
return { schema_version: 1, atomic: true, created, dependencies };
|
|
19833
|
+
});
|
|
19834
|
+
}
|
|
19835
|
+
async function bulkDeleteTasksAtomic(ids, force, store, context) {
|
|
19836
|
+
return store.withDependencyGraphTransaction(async (scoped) => {
|
|
19837
|
+
const resolved = [];
|
|
19838
|
+
for (const reference of ids) {
|
|
19839
|
+
resolved.push({
|
|
19840
|
+
reference,
|
|
19841
|
+
task: await scoped.get("tasks", reference) ?? await scoped.resolveTaskRef(reference)
|
|
19842
|
+
});
|
|
19843
|
+
}
|
|
19844
|
+
const seen = new Set;
|
|
19845
|
+
for (const item of resolved) {
|
|
19846
|
+
if (!item.task)
|
|
19847
|
+
continue;
|
|
19848
|
+
if (seen.has(item.task.id)) {
|
|
19849
|
+
throw new ResourceConflictError("BULK_DELETE_DUPLICATE_TASK", `Multiple references resolve to task ${item.task.id}`);
|
|
19850
|
+
}
|
|
19851
|
+
seen.add(item.task.id);
|
|
19852
|
+
}
|
|
19853
|
+
const childState = new Map;
|
|
19854
|
+
for (const item of resolved) {
|
|
19855
|
+
if (!item.task)
|
|
19856
|
+
continue;
|
|
19857
|
+
const children = await scoped.listTasks({ parent_id: item.task.id, include_subtasks: true, limit: 1 });
|
|
19858
|
+
childState.set(item.task.id, children.length > 0);
|
|
19859
|
+
}
|
|
19860
|
+
const planned = resolved.filter((item) => item.task && (force || !childState.get(item.task.id)));
|
|
19861
|
+
const plannedIds = new Set(planned.map((item) => item.task.id));
|
|
19862
|
+
const roots = [];
|
|
19863
|
+
for (const item of planned) {
|
|
19864
|
+
if (!force) {
|
|
19865
|
+
roots.push(item);
|
|
19866
|
+
continue;
|
|
19867
|
+
}
|
|
19868
|
+
let parentId = item.task.parent_id;
|
|
19869
|
+
let covered = false;
|
|
19870
|
+
while (parentId) {
|
|
19871
|
+
if (plannedIds.has(parentId)) {
|
|
19872
|
+
covered = true;
|
|
19873
|
+
break;
|
|
19874
|
+
}
|
|
19875
|
+
parentId = (await scoped.get("tasks", parentId))?.parent_id ?? null;
|
|
19876
|
+
}
|
|
19877
|
+
if (!covered)
|
|
19878
|
+
roots.push(item);
|
|
19879
|
+
}
|
|
19880
|
+
for (const item of roots) {
|
|
19881
|
+
if (!await scoped.deleteTaskHierarchy(item.task.id, context)) {
|
|
19882
|
+
throw new Error(`Atomic bulk delete lost task ${item.task.id}`);
|
|
19883
|
+
}
|
|
19884
|
+
}
|
|
19885
|
+
return {
|
|
19886
|
+
schema_version: 1,
|
|
19887
|
+
atomic: true,
|
|
19888
|
+
force,
|
|
19889
|
+
results: resolved.map((item) => {
|
|
19890
|
+
if (!item.task)
|
|
19891
|
+
return { requested_id: item.reference, task_id: null, outcome: "missing", reason: "not_found" };
|
|
19892
|
+
if (!force && childState.get(item.task.id)) {
|
|
19893
|
+
return { requested_id: item.reference, task_id: item.task.id, outcome: "skipped", reason: "has_children" };
|
|
19894
|
+
}
|
|
19895
|
+
return { requested_id: item.reference, task_id: item.task.id, outcome: "deleted", reason: null };
|
|
19896
|
+
})
|
|
19897
|
+
};
|
|
19898
|
+
});
|
|
19899
|
+
}
|
|
17360
19900
|
async function createTask2(input, store, context) {
|
|
17361
19901
|
const timestamp2 = new Date().toISOString();
|
|
17362
|
-
const taskId =
|
|
19902
|
+
const taskId = randomUUID4();
|
|
17363
19903
|
await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
|
|
17364
19904
|
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
17365
19905
|
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
@@ -17447,12 +19987,16 @@ async function updateTask2(id, input, store, context) {
|
|
|
17447
19987
|
}
|
|
17448
19988
|
}
|
|
17449
19989
|
}
|
|
19990
|
+
const patchTouchesPlacement = input.project_id !== undefined || input.task_list_id !== undefined;
|
|
19991
|
+
const effectiveProjectIdForShortId = input.project_id !== undefined ? input.project_id : existing.project_id;
|
|
19992
|
+
const backfilledShortId = existing.short_id === null && patchTouchesPlacement && effectiveProjectIdForShortId ? await nextTaskShortId2(effectiveProjectIdForShortId, store, context) : null;
|
|
17450
19993
|
const reopened = existing.status === "completed" && input.status !== undefined && input.status !== "completed" && input.completed_at === undefined;
|
|
17451
19994
|
const terminalNow = input.status !== undefined && isTerminalStatus(input.status);
|
|
17452
19995
|
const task = {
|
|
17453
19996
|
...existing,
|
|
17454
19997
|
...definedPatch(input),
|
|
17455
19998
|
...terminalNow ? { locked_by: null, locked_at: null } : {},
|
|
19999
|
+
short_id: backfilledShortId ?? existing.short_id,
|
|
17456
20000
|
version: existing.version + 1,
|
|
17457
20001
|
updated_at: new Date().toISOString(),
|
|
17458
20002
|
tags: input.tags ?? existing.tags,
|
|
@@ -17581,42 +20125,46 @@ function dependencyId(taskId, dependsOn) {
|
|
|
17581
20125
|
return `${taskId}::${dependsOn}`;
|
|
17582
20126
|
}
|
|
17583
20127
|
async function addDependency2(taskId, dependsOn, store, context) {
|
|
17584
|
-
|
|
17585
|
-
|
|
17586
|
-
|
|
17587
|
-
|
|
17588
|
-
|
|
17589
|
-
|
|
17590
|
-
|
|
17591
|
-
|
|
17592
|
-
|
|
17593
|
-
|
|
17594
|
-
adjacency.
|
|
17595
|
-
|
|
17596
|
-
|
|
17597
|
-
|
|
17598
|
-
|
|
17599
|
-
|
|
17600
|
-
|
|
17601
|
-
|
|
17602
|
-
|
|
17603
|
-
|
|
17604
|
-
|
|
17605
|
-
|
|
17606
|
-
|
|
17607
|
-
|
|
17608
|
-
|
|
17609
|
-
|
|
17610
|
-
|
|
17611
|
-
|
|
17612
|
-
|
|
20128
|
+
return store.withDependencyGraphTransaction(async (store2) => {
|
|
20129
|
+
if (taskId === dependsOn)
|
|
20130
|
+
throw new Error("A task cannot depend on itself");
|
|
20131
|
+
if (!await store2.get("tasks", taskId))
|
|
20132
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
20133
|
+
if (!await store2.get("tasks", dependsOn))
|
|
20134
|
+
throw new Error(`Task not found: ${dependsOn}`);
|
|
20135
|
+
const edges = await store2.list("dependencies");
|
|
20136
|
+
const adjacency = new Map;
|
|
20137
|
+
for (const edge of edges) {
|
|
20138
|
+
if (!adjacency.has(edge.task_id))
|
|
20139
|
+
adjacency.set(edge.task_id, []);
|
|
20140
|
+
adjacency.get(edge.task_id).push(edge.depends_on);
|
|
20141
|
+
}
|
|
20142
|
+
const queue = [dependsOn];
|
|
20143
|
+
const seen = new Set;
|
|
20144
|
+
while (queue.length) {
|
|
20145
|
+
const node = queue.shift();
|
|
20146
|
+
if (node === taskId)
|
|
20147
|
+
throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
|
|
20148
|
+
if (seen.has(node))
|
|
20149
|
+
continue;
|
|
20150
|
+
seen.add(node);
|
|
20151
|
+
for (const next of adjacency.get(node) ?? [])
|
|
20152
|
+
queue.push(next);
|
|
20153
|
+
}
|
|
20154
|
+
const timestamp2 = new Date().toISOString();
|
|
20155
|
+
const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
|
|
20156
|
+
await store2.upsert("dependencies", record, context);
|
|
20157
|
+
return { task_id: taskId, depends_on: dependsOn };
|
|
20158
|
+
});
|
|
17613
20159
|
}
|
|
17614
20160
|
async function removeDependency2(taskId, dependsOn, store) {
|
|
17615
|
-
|
|
17616
|
-
|
|
17617
|
-
|
|
17618
|
-
|
|
17619
|
-
|
|
20161
|
+
return store.withDependencyGraphTransaction(async (store2) => {
|
|
20162
|
+
const existing = await store2.get("dependencies", dependencyId(taskId, dependsOn));
|
|
20163
|
+
if (!existing)
|
|
20164
|
+
return false;
|
|
20165
|
+
await store2.delete("dependencies", dependencyId(taskId, dependsOn));
|
|
20166
|
+
return true;
|
|
20167
|
+
});
|
|
17620
20168
|
}
|
|
17621
20169
|
async function listDependencies(taskId, store) {
|
|
17622
20170
|
const edges = await store.list("dependencies");
|
|
@@ -17632,7 +20180,7 @@ async function addVerification(input, store, context) {
|
|
|
17632
20180
|
throw new Error(`Task not found: ${input.task_id}`);
|
|
17633
20181
|
const timestamp2 = new Date().toISOString();
|
|
17634
20182
|
const verification = {
|
|
17635
|
-
id:
|
|
20183
|
+
id: randomUUID4(),
|
|
17636
20184
|
task_id: input.task_id,
|
|
17637
20185
|
command: input.command,
|
|
17638
20186
|
status: input.status ?? "unknown",
|
|
@@ -17653,7 +20201,7 @@ async function addCommit(input, store, context) {
|
|
|
17653
20201
|
throw new Error(`Task not found: ${input.task_id}`);
|
|
17654
20202
|
const timestamp2 = new Date().toISOString();
|
|
17655
20203
|
const commit = {
|
|
17656
|
-
id:
|
|
20204
|
+
id: randomUUID4(),
|
|
17657
20205
|
task_id: input.task_id,
|
|
17658
20206
|
sha: input.sha,
|
|
17659
20207
|
message: input.message ?? null,
|
|
@@ -17749,7 +20297,7 @@ async function createProject2(input, store, context) {
|
|
|
17749
20297
|
if (!derivedSlug || !taskListId)
|
|
17750
20298
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
17751
20299
|
const parentId = input.parent_id ?? null;
|
|
17752
|
-
const id =
|
|
20300
|
+
const id = randomUUID4();
|
|
17753
20301
|
if (parentId !== null) {
|
|
17754
20302
|
const parent = await store.get("projects", parentId);
|
|
17755
20303
|
if (!parent)
|
|
@@ -17760,6 +20308,9 @@ async function createProject2(input, store, context) {
|
|
|
17760
20308
|
id,
|
|
17761
20309
|
name: input.name,
|
|
17762
20310
|
path: input.path,
|
|
20311
|
+
status: input.status ?? "active",
|
|
20312
|
+
short_id: input.short_id ?? null,
|
|
20313
|
+
metadata: input.metadata ?? {},
|
|
17763
20314
|
description: input.description ?? null,
|
|
17764
20315
|
task_list_id: taskListId,
|
|
17765
20316
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
@@ -17807,6 +20358,9 @@ async function updateProject2(id, input, store) {
|
|
|
17807
20358
|
return store.upsert("projects", updated);
|
|
17808
20359
|
}
|
|
17809
20360
|
async function createPlan2(input, store, context) {
|
|
20361
|
+
const scheduleError = validatePlanSchedule(input);
|
|
20362
|
+
if (scheduleError)
|
|
20363
|
+
throw new Error(scheduleError);
|
|
17810
20364
|
const timestamp2 = new Date().toISOString();
|
|
17811
20365
|
const projectId = input.project_id ?? context?.projectId ?? null;
|
|
17812
20366
|
const slug = await resolvePostgresPlanSlug({
|
|
@@ -17816,7 +20370,7 @@ async function createPlan2(input, store, context) {
|
|
|
17816
20370
|
store
|
|
17817
20371
|
});
|
|
17818
20372
|
return store.upsert("plans", {
|
|
17819
|
-
id:
|
|
20373
|
+
id: randomUUID4(),
|
|
17820
20374
|
slug,
|
|
17821
20375
|
project_id: projectId,
|
|
17822
20376
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -17824,6 +20378,8 @@ async function createPlan2(input, store, context) {
|
|
|
17824
20378
|
name: input.name,
|
|
17825
20379
|
description: input.description ?? null,
|
|
17826
20380
|
status: input.status ?? "active",
|
|
20381
|
+
...input.start_date !== undefined ? { start_date: input.start_date } : {},
|
|
20382
|
+
...input.end_date !== undefined ? { end_date: input.end_date } : {},
|
|
17827
20383
|
created_at: timestamp2,
|
|
17828
20384
|
updated_at: timestamp2,
|
|
17829
20385
|
machine_id: store.machineId(context),
|
|
@@ -17832,6 +20388,9 @@ async function createPlan2(input, store, context) {
|
|
|
17832
20388
|
}
|
|
17833
20389
|
async function updatePlan2(id, input, store) {
|
|
17834
20390
|
const plan = await requireRecord("plans", id, store);
|
|
20391
|
+
const scheduleError = validatePlanSchedule({ ...plan, ...input });
|
|
20392
|
+
if (scheduleError)
|
|
20393
|
+
throw new Error(scheduleError);
|
|
17835
20394
|
const patch = definedPatch(input);
|
|
17836
20395
|
if (input.slug !== undefined) {
|
|
17837
20396
|
patch.slug = await resolvePostgresPlanSlug({
|
|
@@ -17846,8 +20405,8 @@ async function updatePlan2(id, input, store) {
|
|
|
17846
20405
|
...plan,
|
|
17847
20406
|
...patch,
|
|
17848
20407
|
project_id: plan.project_id,
|
|
17849
|
-
updated_at: new Date().toISOString()
|
|
17850
|
-
});
|
|
20408
|
+
updated_at: new Date(Math.max(Date.now(), Date.parse(plan.updated_at) + 1)).toISOString()
|
|
20409
|
+
}, plan.updated_at);
|
|
17851
20410
|
}
|
|
17852
20411
|
function matchAgentByName(agents, name, options) {
|
|
17853
20412
|
const target = normalizeAgentNameInput(name);
|
|
@@ -17868,7 +20427,7 @@ async function registerAgent2(input, store, context) {
|
|
|
17868
20427
|
}
|
|
17869
20428
|
const timestamp2 = new Date().toISOString();
|
|
17870
20429
|
const agent = {
|
|
17871
|
-
id: existing?.id ??
|
|
20430
|
+
id: existing?.id ?? randomUUID4().slice(0, 8),
|
|
17872
20431
|
name: canonicalName,
|
|
17873
20432
|
description: input.description ?? existing?.description ?? null,
|
|
17874
20433
|
role: input.role ?? existing?.role ?? null,
|
|
@@ -17935,18 +20494,24 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
17935
20494
|
const updated = await store.upsert("agents", { ...agent, session_id: null, last_seen_at: new Date().toISOString() }, context);
|
|
17936
20495
|
return { agent: updated, released: true };
|
|
17937
20496
|
}
|
|
20497
|
+
function withTaskListStatus(list) {
|
|
20498
|
+
return { ...list, status: list.status ?? "active" };
|
|
20499
|
+
}
|
|
17938
20500
|
async function createTaskList2(input, store, context) {
|
|
20501
|
+
if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
|
|
20502
|
+
throw new Error("Invalid task-list status");
|
|
17939
20503
|
const timestamp2 = new Date().toISOString();
|
|
17940
20504
|
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
17941
20505
|
if (!slug)
|
|
17942
20506
|
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
17943
20507
|
return store.upsert("task_lists", {
|
|
17944
|
-
id:
|
|
20508
|
+
id: randomUUID4(),
|
|
17945
20509
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
17946
20510
|
slug,
|
|
17947
20511
|
name: input.name,
|
|
17948
20512
|
description: input.description ?? null,
|
|
17949
20513
|
metadata: input.metadata ?? {},
|
|
20514
|
+
status: input.status ?? "active",
|
|
17950
20515
|
created_at: timestamp2,
|
|
17951
20516
|
updated_at: timestamp2,
|
|
17952
20517
|
machine_id: store.machineId(context),
|
|
@@ -17954,6 +20519,8 @@ async function createTaskList2(input, store, context) {
|
|
|
17954
20519
|
}, context);
|
|
17955
20520
|
}
|
|
17956
20521
|
async function updateTaskList2(id, input, store) {
|
|
20522
|
+
if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
|
|
20523
|
+
throw new Error("Invalid task-list status");
|
|
17957
20524
|
const list = await requireRecord("task_lists", id, store);
|
|
17958
20525
|
const patch = definedPatch(input);
|
|
17959
20526
|
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
|
|
@@ -17976,13 +20543,13 @@ async function updateTaskList2(id, input, store) {
|
|
|
17976
20543
|
...list,
|
|
17977
20544
|
...patch,
|
|
17978
20545
|
metadata: input.metadata ?? list.metadata,
|
|
17979
|
-
updated_at: new Date().toISOString()
|
|
20546
|
+
updated_at: new Date(Math.max(Date.now(), Date.parse(list.updated_at) + 1)).toISOString()
|
|
17980
20547
|
});
|
|
17981
20548
|
}
|
|
17982
20549
|
async function createTemplate2(input, store, context) {
|
|
17983
20550
|
const timestamp2 = new Date().toISOString();
|
|
17984
20551
|
const template = {
|
|
17985
|
-
id:
|
|
20552
|
+
id: randomUUID4(),
|
|
17986
20553
|
name: input.name,
|
|
17987
20554
|
title_pattern: input.title_pattern,
|
|
17988
20555
|
description: input.description ?? null,
|
|
@@ -18003,7 +20570,7 @@ async function createTemplate2(input, store, context) {
|
|
|
18003
20570
|
}
|
|
18004
20571
|
function buildTemplateTasks(templateId, inputs, timestamp2) {
|
|
18005
20572
|
return inputs.map((input, position) => ({
|
|
18006
|
-
id:
|
|
20573
|
+
id: randomUUID4(),
|
|
18007
20574
|
template_id: templateId,
|
|
18008
20575
|
position,
|
|
18009
20576
|
title_pattern: input.title_pattern,
|
|
@@ -18021,22 +20588,107 @@ function buildTemplateTasks(templateId, inputs, timestamp2) {
|
|
|
18021
20588
|
async function deleteTemplate2(id, store, context) {
|
|
18022
20589
|
return store.deleteTemplateWithTasks(id, context);
|
|
18023
20590
|
}
|
|
18024
|
-
async function
|
|
20591
|
+
async function templateWithTasks(id, store) {
|
|
18025
20592
|
const template = await store.get("templates", id);
|
|
18026
20593
|
if (!template)
|
|
18027
20594
|
return null;
|
|
18028
|
-
|
|
18029
|
-
|
|
18030
|
-
|
|
18031
|
-
|
|
18032
|
-
|
|
18033
|
-
|
|
18034
|
-
|
|
20595
|
+
const tasks = (await store.list("template_tasks")).filter((row) => row.template_id === id).sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
|
|
20596
|
+
return { ...template, tasks };
|
|
20597
|
+
}
|
|
20598
|
+
async function updateTemplate2(id, input, store) {
|
|
20599
|
+
await store.lockTemplate(id);
|
|
20600
|
+
const current = await templateWithTasks(id, store);
|
|
20601
|
+
if (!current)
|
|
20602
|
+
return null;
|
|
20603
|
+
if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version >= 1e4)
|
|
20604
|
+
throw new Error("Template revision is invalid or exceeds the supported bound");
|
|
20605
|
+
if (input.expected_version !== undefined && input.expected_version !== current.version) {
|
|
20606
|
+
throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template changed; fetch its current version before updating");
|
|
20607
|
+
}
|
|
20608
|
+
const {
|
|
20609
|
+
id: _id,
|
|
20610
|
+
version: _version,
|
|
20611
|
+
created_at: _created,
|
|
20612
|
+
machine_id: _machine,
|
|
20613
|
+
synced_at: _synced,
|
|
20614
|
+
...snapshot
|
|
20615
|
+
} = current;
|
|
20616
|
+
await store.upsert("template_versions", {
|
|
20617
|
+
id: randomUUID4(),
|
|
20618
|
+
template_id: id,
|
|
20619
|
+
version: current.version,
|
|
20620
|
+
snapshot: JSON.stringify(snapshot),
|
|
20621
|
+
created_at: new Date().toISOString()
|
|
18035
20622
|
});
|
|
20623
|
+
const { tasks, ...template } = current;
|
|
20624
|
+
const { expected_version: _expected, ...patch } = input;
|
|
20625
|
+
const updated = await store.persistTemplateRevision({
|
|
20626
|
+
...template,
|
|
20627
|
+
...definedPatch(patch),
|
|
20628
|
+
version: current.version + 1
|
|
20629
|
+
}, current.version);
|
|
20630
|
+
return { ...updated, tasks };
|
|
20631
|
+
}
|
|
20632
|
+
async function readTemplateHistory(id, store) {
|
|
20633
|
+
await store.lockTemplate(id);
|
|
20634
|
+
const current = await store.get("templates", id);
|
|
20635
|
+
if (!current)
|
|
20636
|
+
return null;
|
|
20637
|
+
if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version > 1e4)
|
|
20638
|
+
throw new Error("Template history exceeds the supported version bound");
|
|
20639
|
+
const versions = (await store.list("template_versions")).filter((row) => row.template_id === id).sort((a, b) => b.version - a.version);
|
|
20640
|
+
const seen = new Set;
|
|
20641
|
+
for (const row of versions) {
|
|
20642
|
+
if (!Number.isSafeInteger(row.version) || row.version < 1 || row.version >= current.version || seen.has(row.version))
|
|
20643
|
+
throw new Error("Template history has conflicting versions");
|
|
20644
|
+
seen.add(row.version);
|
|
20645
|
+
}
|
|
20646
|
+
const missing_versions = Array.from({ length: current.version - 1 }, (_, index) => index + 1).filter((version2) => !seen.has(version2));
|
|
20647
|
+
return {
|
|
20648
|
+
current_version: current.version,
|
|
20649
|
+
versions,
|
|
20650
|
+
selection: {
|
|
20651
|
+
schema_version: 1,
|
|
20652
|
+
template_id: id,
|
|
20653
|
+
complete: missing_versions.length === 0,
|
|
20654
|
+
missing_versions
|
|
20655
|
+
}
|
|
20656
|
+
};
|
|
20657
|
+
}
|
|
20658
|
+
async function initializeTemplates(store, context) {
|
|
20659
|
+
const existing = await store.list("templates");
|
|
20660
|
+
const records = [];
|
|
20661
|
+
for (const [definition_index, input] of builtinTemplateInputs().entries()) {
|
|
20662
|
+
const matches = existing.filter((row) => row.name === input.name);
|
|
20663
|
+
if (matches.length) {
|
|
20664
|
+
records.push({
|
|
20665
|
+
definition_index,
|
|
20666
|
+
ids: matches.map((row) => row.id).sort(),
|
|
20667
|
+
name: input.name,
|
|
20668
|
+
status: "skipped"
|
|
20669
|
+
});
|
|
20670
|
+
} else {
|
|
20671
|
+
const template = await createTemplate2(input, store, context);
|
|
20672
|
+
records.push({
|
|
20673
|
+
definition_index,
|
|
20674
|
+
ids: [template.id],
|
|
20675
|
+
name: template.name,
|
|
20676
|
+
status: "created"
|
|
20677
|
+
});
|
|
20678
|
+
}
|
|
20679
|
+
}
|
|
20680
|
+
const names = records.filter((row) => row.status === "created").map((row) => row.name);
|
|
20681
|
+
return {
|
|
20682
|
+
schema_version: 1,
|
|
20683
|
+
created: names.length,
|
|
20684
|
+
skipped: records.length - names.length,
|
|
20685
|
+
names,
|
|
20686
|
+
records
|
|
20687
|
+
};
|
|
18036
20688
|
}
|
|
18037
20689
|
async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
|
|
18038
20690
|
const entry2 = {
|
|
18039
|
-
id:
|
|
20691
|
+
id: randomUUID4(),
|
|
18040
20692
|
task_id: taskId,
|
|
18041
20693
|
action,
|
|
18042
20694
|
field: field ?? null,
|
|
@@ -18050,7 +20702,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
|
|
|
18050
20702
|
}
|
|
18051
20703
|
async function addComment2(input, store, context) {
|
|
18052
20704
|
const comment = {
|
|
18053
|
-
id:
|
|
20705
|
+
id: randomUUID4(),
|
|
18054
20706
|
task_id: input.task_id,
|
|
18055
20707
|
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
18056
20708
|
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
@@ -18066,7 +20718,7 @@ function redactComment(comment) {
|
|
|
18066
20718
|
}
|
|
18067
20719
|
async function addPlanComment2(input, store, context) {
|
|
18068
20720
|
const comment = {
|
|
18069
|
-
id:
|
|
20721
|
+
id: randomUUID4(),
|
|
18070
20722
|
plan_id: input.plan_id,
|
|
18071
20723
|
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
18072
20724
|
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
@@ -18084,6 +20736,7 @@ async function exportSnapshot(store) {
|
|
|
18084
20736
|
return {
|
|
18085
20737
|
exportedAt: new Date().toISOString(),
|
|
18086
20738
|
source: "postgres",
|
|
20739
|
+
machines: await store.list("machines"),
|
|
18087
20740
|
tasks: await store.list("tasks"),
|
|
18088
20741
|
projects: await store.list("projects"),
|
|
18089
20742
|
projectMachinePaths: await store.list("project_machine_paths"),
|
|
@@ -18096,8 +20749,27 @@ async function exportSnapshot(store) {
|
|
|
18096
20749
|
tombstones: await store.listTombstones()
|
|
18097
20750
|
};
|
|
18098
20751
|
}
|
|
18099
|
-
async function importSnapshot(snapshot, store, context) {
|
|
20752
|
+
async function importSnapshot(snapshot, store, context, machines) {
|
|
18100
20753
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
20754
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "atomic_project_migrations")) {
|
|
20755
|
+
result.errors.push("Migration evidence is immutable");
|
|
20756
|
+
return result;
|
|
20757
|
+
}
|
|
20758
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
|
|
20759
|
+
result.errors.push("Machine tombstones require explicit registry lifecycle operations");
|
|
20760
|
+
return result;
|
|
20761
|
+
}
|
|
20762
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "projects")) {
|
|
20763
|
+
result.errors.push("Project tombstones require explicit reference-preserving project deletion");
|
|
20764
|
+
return result;
|
|
20765
|
+
}
|
|
20766
|
+
try {
|
|
20767
|
+
if (snapshot.machines !== undefined)
|
|
20768
|
+
validateMachines(snapshot.machines);
|
|
20769
|
+
} catch (e) {
|
|
20770
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
20771
|
+
return result;
|
|
20772
|
+
}
|
|
18101
20773
|
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
18102
20774
|
if (result.errors.length > 0)
|
|
18103
20775
|
return result;
|
|
@@ -18108,18 +20780,47 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
18108
20780
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
18109
20781
|
if (result.errors.length > 0)
|
|
18110
20782
|
return result;
|
|
20783
|
+
if (new Set(snapshot.projects.map((project) => project.id)).size !== snapshot.projects.length) {
|
|
20784
|
+
result.errors.push("Snapshot contains duplicate project identities");
|
|
20785
|
+
return result;
|
|
20786
|
+
}
|
|
20787
|
+
const orderedProjects = [];
|
|
20788
|
+
const pendingProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
|
|
20789
|
+
while (pendingProjects.size > 0) {
|
|
20790
|
+
const ready = [...pendingProjects.values()].filter((project) => !project.parent_id || !pendingProjects.has(project.parent_id));
|
|
20791
|
+
if (ready.length === 0) {
|
|
20792
|
+
result.errors.push("Snapshot project hierarchy contains a cycle");
|
|
20793
|
+
return result;
|
|
20794
|
+
}
|
|
20795
|
+
for (const project of ready) {
|
|
20796
|
+
orderedProjects.push(project);
|
|
20797
|
+
pendingProjects.delete(project.id);
|
|
20798
|
+
}
|
|
20799
|
+
}
|
|
18111
20800
|
const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
|
|
18112
20801
|
result.errors.push(...auditHistory.errors);
|
|
18113
20802
|
if (result.errors.length > 0)
|
|
18114
20803
|
return result;
|
|
20804
|
+
if (snapshot.machines?.length) {
|
|
20805
|
+
try {
|
|
20806
|
+
if (!machines)
|
|
20807
|
+
throw new Error("Machine registry import is unavailable");
|
|
20808
|
+
const receipt = await machines.execute({ action: "import", machines: snapshot.machines });
|
|
20809
|
+
result.inserted += receipt.inserted;
|
|
20810
|
+
result.skipped += receipt.skipped;
|
|
20811
|
+
} catch (error) {
|
|
20812
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
20813
|
+
return result;
|
|
20814
|
+
}
|
|
20815
|
+
}
|
|
18115
20816
|
result.skipped += auditHistory.identical;
|
|
18116
20817
|
const entries = [
|
|
18117
|
-
...
|
|
18118
|
-
...snapshot.projects.map((row) => ["projects", row]),
|
|
20818
|
+
...orderedProjects.map((row) => ["projects", row]),
|
|
18119
20819
|
...(snapshot.projectMachinePaths ?? []).map((row) => ["project_machine_paths", row]),
|
|
18120
|
-
...snapshot.plans.map((row) => ["plans", row]),
|
|
18121
20820
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
18122
20821
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
20822
|
+
...snapshot.plans.map((row) => ["plans", row]),
|
|
20823
|
+
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
18123
20824
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
18124
20825
|
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
18125
20826
|
];
|
|
@@ -18306,7 +21007,7 @@ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
|
18306
21007
|
lastError = error;
|
|
18307
21008
|
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
18308
21009
|
throw error;
|
|
18309
|
-
await new Promise((
|
|
21010
|
+
await new Promise((resolve10) => setTimeout(resolve10, delayMs * attempt));
|
|
18310
21011
|
}
|
|
18311
21012
|
}
|
|
18312
21013
|
throw lastError;
|
|
@@ -18373,7 +21074,7 @@ class TodosShadowMirror {
|
|
|
18373
21074
|
lastError: this.metrics.lastError
|
|
18374
21075
|
};
|
|
18375
21076
|
}
|
|
18376
|
-
enqueueUpsert(
|
|
21077
|
+
enqueueUpsert(key2, record, context) {
|
|
18377
21078
|
if (!record || typeof record !== "object")
|
|
18378
21079
|
return;
|
|
18379
21080
|
const payload = record;
|
|
@@ -18382,7 +21083,7 @@ class TodosShadowMirror {
|
|
|
18382
21083
|
this.metrics.enqueued += 1;
|
|
18383
21084
|
this.queue.push({
|
|
18384
21085
|
kind: "upsert",
|
|
18385
|
-
key,
|
|
21086
|
+
key: key2,
|
|
18386
21087
|
record: payload,
|
|
18387
21088
|
...context ? { context } : {},
|
|
18388
21089
|
enqueuedAt: Date.now(),
|
|
@@ -18407,8 +21108,8 @@ class TodosShadowMirror {
|
|
|
18407
21108
|
async flush() {
|
|
18408
21109
|
if (this.idle())
|
|
18409
21110
|
return;
|
|
18410
|
-
await new Promise((
|
|
18411
|
-
this.idleResolvers.push(
|
|
21111
|
+
await new Promise((resolve10) => {
|
|
21112
|
+
this.idleResolvers.push(resolve10);
|
|
18412
21113
|
this.pump();
|
|
18413
21114
|
});
|
|
18414
21115
|
}
|
|
@@ -18420,8 +21121,8 @@ class TodosShadowMirror {
|
|
|
18420
21121
|
return;
|
|
18421
21122
|
const resolvers = this.idleResolvers;
|
|
18422
21123
|
this.idleResolvers = [];
|
|
18423
|
-
for (const
|
|
18424
|
-
|
|
21124
|
+
for (const resolve10 of resolvers)
|
|
21125
|
+
resolve10();
|
|
18425
21126
|
}
|
|
18426
21127
|
pump() {
|
|
18427
21128
|
if (this.pumping)
|
|
@@ -18759,8 +21460,8 @@ function createTodosCloudQueryClient(url, options = {}) {
|
|
|
18759
21460
|
};
|
|
18760
21461
|
return client;
|
|
18761
21462
|
}
|
|
18762
|
-
function createTodosCloudQueryClientFromEnv(
|
|
18763
|
-
const url = getTodosStorageDatabaseUrl(
|
|
21463
|
+
function createTodosCloudQueryClientFromEnv(env2 = process.env, options = {}) {
|
|
21464
|
+
const url = getTodosStorageDatabaseUrl(env2);
|
|
18764
21465
|
if (!url)
|
|
18765
21466
|
return null;
|
|
18766
21467
|
return createTodosCloudQueryClient(url, options);
|
|
@@ -18900,7 +21601,7 @@ class TodosShadowOutbox {
|
|
|
18900
21601
|
const remaining = deadline - Date.now();
|
|
18901
21602
|
if (remaining <= 0)
|
|
18902
21603
|
break;
|
|
18903
|
-
await new Promise((
|
|
21604
|
+
await new Promise((resolve10) => setTimeout(resolve10, Math.min(200, remaining)));
|
|
18904
21605
|
}
|
|
18905
21606
|
}
|
|
18906
21607
|
return this.getStats();
|
|
@@ -19078,8 +21779,8 @@ var _capturedDb = null;
|
|
|
19078
21779
|
var _outbox = null;
|
|
19079
21780
|
var _cloud = null;
|
|
19080
21781
|
var _exitRegistered = false;
|
|
19081
|
-
function maybeInstallShadowCapture2(db,
|
|
19082
|
-
if (!isTodosShadowEnabled(
|
|
21782
|
+
function maybeInstallShadowCapture2(db, env2 = process.env) {
|
|
21783
|
+
if (!isTodosShadowEnabled(env2))
|
|
19083
21784
|
return false;
|
|
19084
21785
|
if (_capturedDb === db)
|
|
19085
21786
|
return true;
|
|
@@ -19087,15 +21788,15 @@ function maybeInstallShadowCapture2(db, env = process.env) {
|
|
|
19087
21788
|
_capturedDb = db;
|
|
19088
21789
|
return true;
|
|
19089
21790
|
}
|
|
19090
|
-
function getRuntimeShadowOutbox(db,
|
|
19091
|
-
if (!isTodosShadowEnabled(
|
|
19092
|
-
throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(
|
|
21791
|
+
function getRuntimeShadowOutbox(db, env2 = process.env) {
|
|
21792
|
+
if (!isTodosShadowEnabled(env2)) {
|
|
21793
|
+
throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env2)}=1 to enable it`);
|
|
19093
21794
|
}
|
|
19094
21795
|
if (_outbox && _capturedDb === db)
|
|
19095
21796
|
return _outbox;
|
|
19096
|
-
const cloud = createTodosCloudQueryClientFromEnv(
|
|
21797
|
+
const cloud = createTodosCloudQueryClientFromEnv(env2);
|
|
19097
21798
|
if (!cloud) {
|
|
19098
|
-
throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(
|
|
21799
|
+
throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env2)}`);
|
|
19099
21800
|
}
|
|
19100
21801
|
_cloud = cloud;
|
|
19101
21802
|
installShadowOutboxSchema(db);
|
|
@@ -19103,28 +21804,28 @@ function getRuntimeShadowOutbox(db, env = process.env) {
|
|
|
19103
21804
|
_outbox = new TodosShadowOutbox({ db, postgresClient: cloud });
|
|
19104
21805
|
return _outbox;
|
|
19105
21806
|
}
|
|
19106
|
-
function startRuntimeShadowDrain(db,
|
|
19107
|
-
if (!isTodosShadowEnabled(
|
|
21807
|
+
function startRuntimeShadowDrain(db, env2 = process.env) {
|
|
21808
|
+
if (!isTodosShadowEnabled(env2))
|
|
19108
21809
|
return;
|
|
19109
|
-
maybeInstallShadowCapture2(db,
|
|
21810
|
+
maybeInstallShadowCapture2(db, env2);
|
|
19110
21811
|
try {
|
|
19111
|
-
const outbox = getRuntimeShadowOutbox(db,
|
|
21812
|
+
const outbox = getRuntimeShadowOutbox(db, env2);
|
|
19112
21813
|
outbox.startLoop();
|
|
19113
|
-
registerShadowExitFlush(db,
|
|
21814
|
+
registerShadowExitFlush(db, env2);
|
|
19114
21815
|
} catch (error) {
|
|
19115
21816
|
const message = error instanceof Error ? error.message : String(error);
|
|
19116
21817
|
console.error(`[todos] shadow drain not started: ${message}`);
|
|
19117
21818
|
}
|
|
19118
21819
|
}
|
|
19119
|
-
function registerShadowExitFlush(db,
|
|
21820
|
+
function registerShadowExitFlush(db, env2 = process.env) {
|
|
19120
21821
|
if (_exitRegistered)
|
|
19121
21822
|
return;
|
|
19122
|
-
if (!isTodosShadowEnabled(
|
|
21823
|
+
if (!isTodosShadowEnabled(env2))
|
|
19123
21824
|
return;
|
|
19124
21825
|
_exitRegistered = true;
|
|
19125
21826
|
const flush = async () => {
|
|
19126
21827
|
try {
|
|
19127
|
-
const outbox = getRuntimeShadowOutbox(db,
|
|
21828
|
+
const outbox = getRuntimeShadowOutbox(db, env2);
|
|
19128
21829
|
await outbox.flush(5000);
|
|
19129
21830
|
} catch {} finally {
|
|
19130
21831
|
await closeRuntimeShadowCloud();
|
|
@@ -19282,7 +21983,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
19282
21983
|
return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
|
|
19283
21984
|
}
|
|
19284
21985
|
// src/storage/s3-artifacts.ts
|
|
19285
|
-
import { createHash as
|
|
21986
|
+
import { createHash as createHash7, createHmac as createHmac2 } from "crypto";
|
|
19286
21987
|
function createTodosS3ArtifactStore(options) {
|
|
19287
21988
|
const requestFetch = options.fetch ?? fetch;
|
|
19288
21989
|
const now3 = options.now ?? (() => new Date);
|
|
@@ -19290,8 +21991,8 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19290
21991
|
objectKey: (relativePath) => buildS3ObjectKey(options.config, relativePath),
|
|
19291
21992
|
objectUrl: (relativePath) => buildS3ObjectUrl(options.config, buildS3ObjectKey(options.config, relativePath)),
|
|
19292
21993
|
async putObject(input) {
|
|
19293
|
-
const
|
|
19294
|
-
const url = buildS3ObjectUrl(options.config,
|
|
21994
|
+
const key2 = buildS3ObjectKey(options.config, input.key);
|
|
21995
|
+
const url = buildS3ObjectUrl(options.config, key2);
|
|
19295
21996
|
const body = normalizeBody(input.body);
|
|
19296
21997
|
const headers = {
|
|
19297
21998
|
"content-type": input.contentType ?? "application/octet-stream"
|
|
@@ -19314,7 +22015,7 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19314
22015
|
throw new Error(`S3 put failed with HTTP ${response.status}`);
|
|
19315
22016
|
return {
|
|
19316
22017
|
bucket: options.config.bucket,
|
|
19317
|
-
key,
|
|
22018
|
+
key: key2,
|
|
19318
22019
|
url: url.toString(),
|
|
19319
22020
|
etag: response.headers.get("etag") ?? undefined
|
|
19320
22021
|
};
|
|
@@ -19354,11 +22055,11 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19354
22055
|
}
|
|
19355
22056
|
function buildS3ObjectKey(config, relativePath) {
|
|
19356
22057
|
const prefix = normalizeS3Prefix(config.prefix);
|
|
19357
|
-
const
|
|
19358
|
-
return `${prefix}${
|
|
22058
|
+
const key2 = normalizeS3Key(relativePath);
|
|
22059
|
+
return `${prefix}${key2}`;
|
|
19359
22060
|
}
|
|
19360
|
-
function buildS3ObjectUrl(config,
|
|
19361
|
-
const encodedKey = encodeS3Path(
|
|
22061
|
+
function buildS3ObjectUrl(config, key2) {
|
|
22062
|
+
const encodedKey = encodeS3Path(key2);
|
|
19362
22063
|
if (config.endpoint || config.forcePathStyle) {
|
|
19363
22064
|
const base = new URL(config.endpoint ?? `https://s3.${config.region ?? "us-east-1"}.amazonaws.com`);
|
|
19364
22065
|
base.pathname = `${trimTrailingSlash(base.pathname)}/${encodeURIComponent(config.bucket)}/${encodedKey}`;
|
|
@@ -19422,25 +22123,25 @@ function normalizeBody(body) {
|
|
|
19422
22123
|
throw new Error("S3 body must be a string, Uint8Array, Buffer, or ArrayBuffer");
|
|
19423
22124
|
}
|
|
19424
22125
|
function normalizeS3Prefix(value) {
|
|
19425
|
-
const
|
|
19426
|
-
if (!
|
|
22126
|
+
const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
22127
|
+
if (!normalized2 || normalized2.includes(".."))
|
|
19427
22128
|
throw new Error("Invalid S3 object key");
|
|
19428
|
-
return
|
|
22129
|
+
return normalized2.endsWith("/") ? normalized2 : `${normalized2}/`;
|
|
19429
22130
|
}
|
|
19430
22131
|
function normalizeS3Key(value) {
|
|
19431
|
-
const
|
|
19432
|
-
if (!
|
|
22132
|
+
const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
22133
|
+
if (!normalized2 || normalized2.includes("..") || normalized2.endsWith("/"))
|
|
19433
22134
|
throw new Error("Invalid S3 object key");
|
|
19434
|
-
return
|
|
22135
|
+
return normalized2;
|
|
19435
22136
|
}
|
|
19436
|
-
function encodeS3Path(
|
|
19437
|
-
return
|
|
22137
|
+
function encodeS3Path(key2) {
|
|
22138
|
+
return key2.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
|
|
19438
22139
|
}
|
|
19439
22140
|
function trimTrailingSlash(value) {
|
|
19440
22141
|
return value.replace(/\/+$/, "");
|
|
19441
22142
|
}
|
|
19442
22143
|
function canonicalQuery(url) {
|
|
19443
|
-
return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([
|
|
22144
|
+
return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key2, value]) => `${encodeURIComponent(key2)}=${encodeURIComponent(value)}`).join("&");
|
|
19444
22145
|
}
|
|
19445
22146
|
function normalizeHeaders(headers) {
|
|
19446
22147
|
const entries = Object.entries(headers).map(([name, value]) => [
|
|
@@ -19454,13 +22155,13 @@ function toAmzDate(date) {
|
|
|
19454
22155
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
19455
22156
|
}
|
|
19456
22157
|
function sha256Hex(value) {
|
|
19457
|
-
return
|
|
22158
|
+
return createHash7("sha256").update(value).digest("hex");
|
|
19458
22159
|
}
|
|
19459
|
-
function hmac(
|
|
19460
|
-
return createHmac2("sha256",
|
|
22160
|
+
function hmac(key2, value) {
|
|
22161
|
+
return createHmac2("sha256", key2).update(value).digest();
|
|
19461
22162
|
}
|
|
19462
|
-
function hmacHex(
|
|
19463
|
-
return createHmac2("sha256",
|
|
22163
|
+
function hmacHex(key2, value) {
|
|
22164
|
+
return createHmac2("sha256", key2).update(value).digest("hex");
|
|
19464
22165
|
}
|
|
19465
22166
|
function getSigningKey(secretAccessKey, dateStamp, region, service) {
|
|
19466
22167
|
const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
|
@@ -19494,7 +22195,7 @@ async function uploadRunArtifactsToS3(options) {
|
|
|
19494
22195
|
continue;
|
|
19495
22196
|
}
|
|
19496
22197
|
const ref = await options.store.putObject({
|
|
19497
|
-
key: content.
|
|
22198
|
+
key: remoteArtifactKey(artifact.task_id, content.sha256),
|
|
19498
22199
|
body: Buffer.from(content.base64, "base64"),
|
|
19499
22200
|
contentType: mediaType(metadata) ?? "application/octet-stream",
|
|
19500
22201
|
metadata: {
|
|
@@ -19508,7 +22209,8 @@ async function uploadRunArtifactsToS3(options) {
|
|
|
19508
22209
|
provider: "s3",
|
|
19509
22210
|
bucket: ref.bucket,
|
|
19510
22211
|
key: ref.key,
|
|
19511
|
-
relative_path: content.
|
|
22212
|
+
relative_path: remoteArtifactKey(artifact.task_id, content.sha256),
|
|
22213
|
+
local_path: content.relative_path,
|
|
19512
22214
|
url: ref.url,
|
|
19513
22215
|
sha256: content.sha256,
|
|
19514
22216
|
size_bytes: content.size_bytes,
|
|
@@ -19612,7 +22314,7 @@ async function downloadRunArtifactsFromS3(options) {
|
|
|
19612
22314
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
19613
22315
|
const report = importStoredArtifactContent({
|
|
19614
22316
|
artifact_id: artifact.id,
|
|
19615
|
-
relative_path: remote.relative_path,
|
|
22317
|
+
relative_path: remote.local_path ?? remote.relative_path,
|
|
19616
22318
|
sha256: remote.sha256,
|
|
19617
22319
|
size_bytes: remote.size_bytes,
|
|
19618
22320
|
base64: bytes.toString("base64")
|
|
@@ -19644,6 +22346,12 @@ async function downloadRunArtifactsFromS3(options) {
|
|
|
19644
22346
|
function emptyResult() {
|
|
19645
22347
|
return { uploaded: 0, downloaded: 0, skipped: 0, errors: [], artifacts: [] };
|
|
19646
22348
|
}
|
|
22349
|
+
function remoteArtifactKey(taskId, sha2562) {
|
|
22350
|
+
if (!/^[0-9a-f]{64}$/.test(sha2562)) {
|
|
22351
|
+
throw new Error("run artifact digest must be a lowercase hex sha-256; refusing to build an object key from it");
|
|
22352
|
+
}
|
|
22353
|
+
return `artifacts/${taskId}/${sha2562}`;
|
|
22354
|
+
}
|
|
19647
22355
|
function planArtifact(artifact, status, remote) {
|
|
19648
22356
|
return {
|
|
19649
22357
|
id: artifact.id,
|
|
@@ -19707,10 +22415,106 @@ function mediaType(metadata) {
|
|
|
19707
22415
|
const value = store["media_type"];
|
|
19708
22416
|
return typeof value === "string" ? value : null;
|
|
19709
22417
|
}
|
|
22418
|
+
// src/storage/run-artifact-creation-upload.ts
|
|
22419
|
+
init_database();
|
|
22420
|
+
init_artifact_store();
|
|
22421
|
+
init_config();
|
|
22422
|
+
async function uploadRunArtifactAtCreation(input) {
|
|
22423
|
+
const db = input.db ?? getDatabase();
|
|
22424
|
+
const now3 = input.now ?? (() => new Date);
|
|
22425
|
+
try {
|
|
22426
|
+
const artifact = getRunArtifactById(db, input.artifactId);
|
|
22427
|
+
if (!artifact)
|
|
22428
|
+
return { uploaded: false, reason: "artifact_not_found" };
|
|
22429
|
+
const metadata = artifact.metadata;
|
|
22430
|
+
const existing = remoteRef(metadata);
|
|
22431
|
+
if (existing)
|
|
22432
|
+
return { uploaded: true, reason: "already_remote", ref: existing };
|
|
22433
|
+
if (!input.store) {
|
|
22434
|
+
const config = loadTodosStorageConfig(input.env ?? process.env);
|
|
22435
|
+
if (!config.objectStorage)
|
|
22436
|
+
return { uploaded: false, reason: "s3_not_configured" };
|
|
22437
|
+
const credentials = creationCredentialsFromEnv(input.env ?? process.env);
|
|
22438
|
+
if (!credentials)
|
|
22439
|
+
return { uploaded: false, reason: "s3_credentials_missing" };
|
|
22440
|
+
input.store = createTodosS3ArtifactStore({ config: config.objectStorage, credentials });
|
|
22441
|
+
}
|
|
22442
|
+
const store = input.store;
|
|
22443
|
+
const content = exportStoredArtifactContent({
|
|
22444
|
+
id: artifact.id,
|
|
22445
|
+
path: artifact.path,
|
|
22446
|
+
size_bytes: artifact.size_bytes,
|
|
22447
|
+
sha256: artifact.sha256,
|
|
22448
|
+
metadata
|
|
22449
|
+
});
|
|
22450
|
+
if (!content)
|
|
22451
|
+
return { uploaded: false, reason: "no_local_content" };
|
|
22452
|
+
const relativePath = remoteArtifactKey(artifact.task_id, content.sha256);
|
|
22453
|
+
const ref = await store.putObject({
|
|
22454
|
+
key: relativePath,
|
|
22455
|
+
body: Buffer.from(content.base64, "base64"),
|
|
22456
|
+
contentType: mediaType2(metadata) ?? "application/octet-stream",
|
|
22457
|
+
metadata: {
|
|
22458
|
+
artifact_id: artifact.id,
|
|
22459
|
+
run_id: artifact.run_id,
|
|
22460
|
+
task_id: artifact.task_id,
|
|
22461
|
+
sha256: content.sha256
|
|
22462
|
+
}
|
|
22463
|
+
});
|
|
22464
|
+
const remote = {
|
|
22465
|
+
provider: "s3",
|
|
22466
|
+
bucket: ref.bucket,
|
|
22467
|
+
key: ref.key,
|
|
22468
|
+
relative_path: relativePath,
|
|
22469
|
+
local_path: content.relative_path,
|
|
22470
|
+
url: ref.url,
|
|
22471
|
+
sha256: content.sha256,
|
|
22472
|
+
size_bytes: content.size_bytes,
|
|
22473
|
+
uploaded_at: now3().toISOString()
|
|
22474
|
+
};
|
|
22475
|
+
updateArtifactMetadata(db, artifact.id, {
|
|
22476
|
+
...metadata,
|
|
22477
|
+
remote_artifact_store: remote
|
|
22478
|
+
});
|
|
22479
|
+
return { uploaded: true, reason: "uploaded", ref: remote };
|
|
22480
|
+
} catch (error) {
|
|
22481
|
+
return {
|
|
22482
|
+
uploaded: false,
|
|
22483
|
+
reason: "upload_error",
|
|
22484
|
+
error: error instanceof Error ? error.message : String(error)
|
|
22485
|
+
};
|
|
22486
|
+
}
|
|
22487
|
+
}
|
|
22488
|
+
function creationCredentialsFromEnv(env2) {
|
|
22489
|
+
const accessKeyId = readCredentialEnv(env2, "s3AccessKeyId");
|
|
22490
|
+
const secretAccessKey = readCredentialEnv(env2, "s3SecretAccessKey");
|
|
22491
|
+
if (!accessKeyId || !secretAccessKey)
|
|
22492
|
+
return null;
|
|
22493
|
+
const sessionToken = readCredentialEnv(env2, "s3SessionToken");
|
|
22494
|
+
return sessionToken ? { accessKeyId, secretAccessKey, sessionToken } : { accessKeyId, secretAccessKey };
|
|
22495
|
+
}
|
|
22496
|
+
function readCredentialEnv(env2, key2) {
|
|
22497
|
+
const value = env2[TODOS_STORAGE_ENV[key2]] ?? env2[TODOS_STORAGE_FALLBACK_ENV[key2]];
|
|
22498
|
+
const trimmed = value?.trim();
|
|
22499
|
+
return trimmed ? trimmed : undefined;
|
|
22500
|
+
}
|
|
22501
|
+
function getRunArtifactById(db, id) {
|
|
22502
|
+
const row = db.query("SELECT * FROM task_run_artifacts WHERE id = ?").get(id);
|
|
22503
|
+
return row ? { ...row, metadata: parseMetadata2(row.metadata) } : null;
|
|
22504
|
+
}
|
|
22505
|
+
function mediaType2(metadata) {
|
|
22506
|
+
const store = metadata["artifact_store"];
|
|
22507
|
+
if (!store || typeof store !== "object" || Array.isArray(store))
|
|
22508
|
+
return null;
|
|
22509
|
+
const value = store["media_type"];
|
|
22510
|
+
return typeof value === "string" ? value : null;
|
|
22511
|
+
}
|
|
19710
22512
|
export {
|
|
19711
22513
|
uploadRunArtifactsToS3,
|
|
22514
|
+
uploadRunArtifactAtCreation,
|
|
19712
22515
|
startRuntimeShadowDrain,
|
|
19713
22516
|
signAwsV4Request,
|
|
22517
|
+
remoteArtifactKey,
|
|
19714
22518
|
registerShadowExitFlush,
|
|
19715
22519
|
postgresTodosSyncSchemaSql,
|
|
19716
22520
|
postgresTodosScopedSlugUniqueIndexSql,
|