@hasna/todos 0.15.52 → 0.16.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 +1884 -0
- package/README.md +314 -37
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai.d.ts.map +1 -1
- package/dist/cli/cloud-router.d.ts +99 -51
- 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 +15128 -11093
- 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 +34 -4
- 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/contracts.js +835 -265
- 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.map +1 -1
- package/dist/db/handoffs.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-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 +3862 -1715
- 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/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 +90 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/paths.d.ts +9 -0
- 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 +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +45271 -40452
- 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/environment-snapshots.d.ts.map +1 -1
- package/dist/mcp/tools/machines.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.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +44 -18
- package/dist/project-registration.js +2829 -556
- package/dist/registry.js +836 -266
- package/dist/release-provenance.json +6 -6
- package/dist/sdk/client.d.ts +51 -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 +1209 -1080
- package/dist/sdk/resolve.d.ts +91 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +219 -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 +20556 -15541
- package/dist/server/openapi.d.ts +1341 -83
- 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/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 +3 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +61 -0
- package/dist/storage/interfaces.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 +2695 -322
- package/dist/task-manifest/postgres.d.ts +1 -1
- package/dist/task-manifest/sqlite.d.ts +1 -1
- package/dist/task-manifest.js +244 -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 +40 -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/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",
|
|
@@ -4282,108 +4399,100 @@ var init_identity_mapping = __esm(() => {
|
|
|
4282
4399
|
});
|
|
4283
4400
|
});
|
|
4284
4401
|
|
|
4285
|
-
//
|
|
4402
|
+
// src/lib/paths.ts
|
|
4403
|
+
import { existsSync as existsSync2 } from "fs";
|
|
4286
4404
|
import { homedir } from "os";
|
|
4287
|
-
import { join } from "path";
|
|
4288
|
-
|
|
4405
|
+
import { join, resolve as resolve2 } from "path";
|
|
4406
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
4407
|
+
import { join as pathsResolverJoin } from "path";
|
|
4408
|
+
function pathsResolverAssertApp(app) {
|
|
4289
4409
|
if (typeof app !== "string" || app.length === 0) {
|
|
4290
4410
|
throw new TypeError("paths: app must be a non-empty string");
|
|
4291
4411
|
}
|
|
4292
|
-
if (!
|
|
4412
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
4293
4413
|
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
4294
4414
|
}
|
|
4295
4415
|
}
|
|
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";
|
|
4416
|
+
function pathsResolverAssertKind(kind) {
|
|
4417
|
+
if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
|
|
4418
|
+
throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
|
|
4419
|
+
}
|
|
4305
4420
|
}
|
|
4306
|
-
function
|
|
4307
|
-
|
|
4308
|
-
|
|
4421
|
+
function pathsResolverBaseDir(kind, options) {
|
|
4422
|
+
pathsResolverAssertKind(kind);
|
|
4423
|
+
const env2 = options.env ?? process.env;
|
|
4424
|
+
const override = env2[PATHS_RESOLVER_KIND_ENV[kind]];
|
|
4425
|
+
if (typeof override === "string" && override.length > 0)
|
|
4309
4426
|
return override;
|
|
4310
|
-
const home = options.home ??
|
|
4427
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
4311
4428
|
const platform = options.platform ?? process.platform;
|
|
4312
|
-
if (
|
|
4429
|
+
if (platform === "darwin") {
|
|
4313
4430
|
switch (kind) {
|
|
4314
4431
|
case "config":
|
|
4315
4432
|
case "data":
|
|
4316
|
-
return
|
|
4433
|
+
return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
|
|
4317
4434
|
case "cache":
|
|
4318
|
-
return
|
|
4435
|
+
return pathsResolverJoin(home, "Library", "Caches", "Hasna");
|
|
4319
4436
|
case "state":
|
|
4320
|
-
return
|
|
4437
|
+
return pathsResolverJoin(home, "Library", "Logs", "Hasna");
|
|
4321
4438
|
}
|
|
4322
4439
|
}
|
|
4323
4440
|
switch (kind) {
|
|
4324
4441
|
case "config":
|
|
4325
|
-
return
|
|
4442
|
+
return pathsResolverJoin(home, ".config", "hasna");
|
|
4326
4443
|
case "data":
|
|
4327
|
-
return
|
|
4444
|
+
return pathsResolverJoin(home, ".local", "share", "hasna");
|
|
4328
4445
|
case "state":
|
|
4329
|
-
return
|
|
4446
|
+
return pathsResolverJoin(home, ".local", "state", "hasna");
|
|
4330
4447
|
case "cache":
|
|
4331
|
-
return
|
|
4448
|
+
return pathsResolverJoin(home, ".cache", "hasna");
|
|
4332
4449
|
}
|
|
4333
4450
|
}
|
|
4334
|
-
function
|
|
4335
|
-
|
|
4336
|
-
const appSegment = options.internal === true ?
|
|
4337
|
-
return
|
|
4451
|
+
function pathsResolverResolve(kind, options) {
|
|
4452
|
+
pathsResolverAssertApp(options.app);
|
|
4453
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
4454
|
+
return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
|
|
4338
4455
|
}
|
|
4339
4456
|
function dataDir(options) {
|
|
4340
|
-
return
|
|
4457
|
+
return pathsResolverResolve("data", options);
|
|
4341
4458
|
}
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
KIND_ENV = {
|
|
4345
|
-
config: "HASNA_CONFIG_HOME",
|
|
4346
|
-
data: "HASNA_DATA_HOME",
|
|
4347
|
-
state: "HASNA_STATE_HOME",
|
|
4348
|
-
cache: "HASNA_CACHE_HOME"
|
|
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();
|
|
4459
|
+
function effectiveHome(env2 = process.env) {
|
|
4460
|
+
return env2.HOME || env2.USERPROFILE || homedir();
|
|
4359
4461
|
}
|
|
4360
|
-
function legacyHomeDir(
|
|
4361
|
-
return
|
|
4462
|
+
function legacyHomeDir(env2 = process.env) {
|
|
4463
|
+
return join(effectiveHome(env2), ".hasna", "todos");
|
|
4362
4464
|
}
|
|
4363
|
-
function resolverHome(
|
|
4364
|
-
return dataDir({ app: "todos", home: effectiveHome(
|
|
4465
|
+
function resolverHome(env2 = process.env) {
|
|
4466
|
+
return dataDir({ app: "todos", home: effectiveHome(env2), env: env2 });
|
|
4365
4467
|
}
|
|
4366
|
-
function adoptResolverHome(resolved,
|
|
4367
|
-
const dataOverride =
|
|
4468
|
+
function adoptResolverHome(resolved, env2 = process.env) {
|
|
4469
|
+
const dataOverride = env2.HASNA_DATA_HOME;
|
|
4368
4470
|
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
4369
4471
|
return true;
|
|
4370
|
-
return existsSync2(
|
|
4472
|
+
return existsSync2(join(resolved, "todos.db")) || existsSync2(join(resolved, "config.json"));
|
|
4371
4473
|
}
|
|
4372
|
-
function getTodosDir(
|
|
4373
|
-
const resolved = resolverHome(
|
|
4374
|
-
return resolve2(adoptResolverHome(resolved,
|
|
4474
|
+
function getTodosDir(env2 = process.env) {
|
|
4475
|
+
const resolved = resolverHome(env2);
|
|
4476
|
+
return resolve2(adoptResolverHome(resolved, env2) ? resolved : legacyHomeDir(env2));
|
|
4375
4477
|
}
|
|
4478
|
+
var PATHS_RESOLVER_KIND_ENV, PATHS_RESOLVER_APP_SLUG_RE;
|
|
4376
4479
|
var init_paths = __esm(() => {
|
|
4377
|
-
|
|
4480
|
+
PATHS_RESOLVER_KIND_ENV = {
|
|
4481
|
+
config: "HASNA_CONFIG_HOME",
|
|
4482
|
+
data: "HASNA_DATA_HOME",
|
|
4483
|
+
state: "HASNA_STATE_HOME",
|
|
4484
|
+
cache: "HASNA_CACHE_HOME"
|
|
4485
|
+
};
|
|
4486
|
+
PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4378
4487
|
});
|
|
4379
4488
|
|
|
4380
4489
|
// src/lib/sync-utils.ts
|
|
4381
4490
|
import { existsSync as existsSync3, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
4382
4491
|
import { createHash } from "crypto";
|
|
4383
|
-
import { homedir as
|
|
4384
|
-
import { join as
|
|
4492
|
+
import { homedir as homedir2 } from "os";
|
|
4493
|
+
import { join as join2 } from "path";
|
|
4385
4494
|
function getHomeDir() {
|
|
4386
|
-
return process.env["HOME"] || process.env["USERPROFILE"] ||
|
|
4495
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
|
|
4387
4496
|
}
|
|
4388
4497
|
function getTodosGlobalDir() {
|
|
4389
4498
|
return getTodosDir();
|
|
@@ -4409,14 +4518,14 @@ function writeJsonFile(path, data) {
|
|
|
4409
4518
|
`);
|
|
4410
4519
|
}
|
|
4411
4520
|
function readHighWaterMark(dir) {
|
|
4412
|
-
const path =
|
|
4521
|
+
const path = join2(dir, ".highwatermark");
|
|
4413
4522
|
if (!existsSync3(path))
|
|
4414
4523
|
return 1;
|
|
4415
4524
|
const val = parseInt(readFileSync(path, "utf-8").trim(), 10);
|
|
4416
4525
|
return isNaN(val) ? 1 : val;
|
|
4417
4526
|
}
|
|
4418
4527
|
function writeHighWaterMark(dir, value) {
|
|
4419
|
-
writeFileSync(
|
|
4528
|
+
writeFileSync(join2(dir, ".highwatermark"), String(value));
|
|
4420
4529
|
}
|
|
4421
4530
|
function getFileMtimeMs(path) {
|
|
4422
4531
|
try {
|
|
@@ -4576,7 +4685,7 @@ __export(exports_database, {
|
|
|
4576
4685
|
});
|
|
4577
4686
|
import { Database } from "bun:sqlite";
|
|
4578
4687
|
import { existsSync as existsSync4, mkdirSync as mkdirSync2 } from "fs";
|
|
4579
|
-
import { dirname, join as
|
|
4688
|
+
import { dirname, join as join3, resolve as resolve3 } from "path";
|
|
4580
4689
|
function isInMemoryDb(path) {
|
|
4581
4690
|
return path === ":memory:" || path.startsWith("file::memory:");
|
|
4582
4691
|
}
|
|
@@ -4585,7 +4694,7 @@ function findNearestProjectDb(startDir) {
|
|
|
4585
4694
|
const stopAt = gitRoot ? resolve3(gitRoot) : resolve3(startDir);
|
|
4586
4695
|
let dir = resolve3(startDir);
|
|
4587
4696
|
while (true) {
|
|
4588
|
-
const candidate =
|
|
4697
|
+
const candidate = join3(dir, ".hasna", "todos", "todos.db");
|
|
4589
4698
|
if (existsSync4(candidate))
|
|
4590
4699
|
return candidate;
|
|
4591
4700
|
if (dir === stopAt)
|
|
@@ -4600,7 +4709,7 @@ function findNearestProjectDb(startDir) {
|
|
|
4600
4709
|
function findGitRoot(startDir) {
|
|
4601
4710
|
let dir = resolve3(startDir);
|
|
4602
4711
|
while (true) {
|
|
4603
|
-
if (existsSync4(
|
|
4712
|
+
if (existsSync4(join3(dir, ".git")))
|
|
4604
4713
|
return dir;
|
|
4605
4714
|
const parent = dirname(dir);
|
|
4606
4715
|
if (parent === dir)
|
|
@@ -4610,7 +4719,7 @@ function findGitRoot(startDir) {
|
|
|
4610
4719
|
return null;
|
|
4611
4720
|
}
|
|
4612
4721
|
function getGlobalDbPath() {
|
|
4613
|
-
return
|
|
4722
|
+
return join3(getTodosGlobalDir(), "todos.db");
|
|
4614
4723
|
}
|
|
4615
4724
|
function hasExplicitProjectArg(args = process.argv.slice(2)) {
|
|
4616
4725
|
return args.some((arg) => arg === "--project" || arg.startsWith("--project="));
|
|
@@ -4648,7 +4757,7 @@ function getDbPath() {
|
|
|
4648
4757
|
if (process.env["TODOS_DB_SCOPE"] === "project") {
|
|
4649
4758
|
const gitRoot = findGitRoot(cwd);
|
|
4650
4759
|
if (gitRoot && canCreateScopedProjectDb()) {
|
|
4651
|
-
return
|
|
4760
|
+
return join3(gitRoot, ".hasna", "todos", "todos.db");
|
|
4652
4761
|
}
|
|
4653
4762
|
}
|
|
4654
4763
|
return getGlobalDbPath();
|
|
@@ -4689,6 +4798,9 @@ function maybeInstallShadowCapture(db) {
|
|
|
4689
4798
|
}
|
|
4690
4799
|
}
|
|
4691
4800
|
function getDatabase(dbPath) {
|
|
4801
|
+
if (dbPath === undefined && !selectsTodosLocalStore()) {
|
|
4802
|
+
throw new Error("API_DATABASE_FALLBACK_FORBIDDEN: this operation must use the shared Todos API; implicit SQLite access is unavailable");
|
|
4803
|
+
}
|
|
4692
4804
|
const path = dbPath || getDbPath();
|
|
4693
4805
|
if (_db && _dbPath === path)
|
|
4694
4806
|
return _db;
|
|
@@ -4828,6 +4940,7 @@ function lowerInClause(column, values, params) {
|
|
|
4828
4940
|
}
|
|
4829
4941
|
var LOCK_EXPIRY_MINUTES = 30, _db = null, _dbPath = null, ALLOWED_TABLES;
|
|
4830
4942
|
var init_database = __esm(() => {
|
|
4943
|
+
init_local_opt_in();
|
|
4831
4944
|
init_schema();
|
|
4832
4945
|
init_machines();
|
|
4833
4946
|
init_identity_mapping();
|
|
@@ -4838,9 +4951,9 @@ var init_database = __esm(() => {
|
|
|
4838
4951
|
|
|
4839
4952
|
// src/lib/config.ts
|
|
4840
4953
|
import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
|
4841
|
-
import { dirname as dirname2, join as
|
|
4954
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
4842
4955
|
function getConfigPath() {
|
|
4843
|
-
return
|
|
4956
|
+
return join4(getTodosGlobalDir(), "config.json");
|
|
4844
4957
|
}
|
|
4845
4958
|
function normalizeAgent(agent) {
|
|
4846
4959
|
return agent.trim().toLowerCase();
|
|
@@ -4891,21 +5004,6 @@ function normalizeApiUrl(value) {
|
|
|
4891
5004
|
return null;
|
|
4892
5005
|
return trimmed.replace(/\/+$/, "");
|
|
4893
5006
|
}
|
|
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
5007
|
function getSyncAgentsFromConfig() {
|
|
4910
5008
|
const config = loadConfig();
|
|
4911
5009
|
const agents = config.sync_agents;
|
|
@@ -5756,11 +5854,11 @@ function writeAllowed(profile, root, writePath) {
|
|
|
5756
5854
|
return isPathInside(scopeRoot, target);
|
|
5757
5855
|
});
|
|
5758
5856
|
}
|
|
5759
|
-
function redactedEnvKeys(profile,
|
|
5760
|
-
if (!
|
|
5857
|
+
function redactedEnvKeys(profile, env2) {
|
|
5858
|
+
if (!env2)
|
|
5761
5859
|
return [];
|
|
5762
5860
|
const patterns = unique2([...DEFAULT_ENV_REDACTIONS, ...profile.env_redactions]).map((item) => item.toUpperCase());
|
|
5763
|
-
return Object.keys(
|
|
5861
|
+
return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
|
|
5764
5862
|
}
|
|
5765
5863
|
function checkWorkspacePermission(input = {}) {
|
|
5766
5864
|
const status = getWorkspaceTrustStatus(input.path || process.cwd());
|
|
@@ -5898,18 +5996,18 @@ function profileByName(name, path) {
|
|
|
5898
5996
|
const resolved = normalizePath2(path);
|
|
5899
5997
|
return profiles.find((profile) => isPathInside2(profile.root, resolved)) || defaultProfile2("default", resolved);
|
|
5900
5998
|
}
|
|
5901
|
-
function redactedEnvKeys2(profile,
|
|
5902
|
-
if (!
|
|
5999
|
+
function redactedEnvKeys2(profile, env2) {
|
|
6000
|
+
if (!env2)
|
|
5903
6001
|
return [];
|
|
5904
6002
|
const patterns = unique3([...DEFAULT_ENV_REDACTIONS2, ...profile.env_redactions]).map((item) => item.toUpperCase());
|
|
5905
|
-
return Object.keys(
|
|
6003
|
+
return Object.keys(env2).filter((key) => patterns.some((pattern) => key.toUpperCase().includes(pattern)));
|
|
5906
6004
|
}
|
|
5907
|
-
function omittedEnvKeys(profile,
|
|
5908
|
-
if (!
|
|
6005
|
+
function omittedEnvKeys(profile, env2) {
|
|
6006
|
+
if (!env2)
|
|
5909
6007
|
return [];
|
|
5910
6008
|
if (profile.env_allowlist.includes("*"))
|
|
5911
6009
|
return [];
|
|
5912
|
-
return Object.keys(
|
|
6010
|
+
return Object.keys(env2).filter((key) => !profile.env_allowlist.some((pattern) => matchesPattern2(key, pattern)));
|
|
5913
6011
|
}
|
|
5914
6012
|
function resolveFromCwd(cwd, path) {
|
|
5915
6013
|
return normalizePath2(path.startsWith("/") ? path : `${cwd}/${path}`);
|
|
@@ -6292,15 +6390,23 @@ var init_event_hooks = __esm(() => {
|
|
|
6292
6390
|
VALID_TARGETS = new Set(["stdout", "file", "socket", "script"]);
|
|
6293
6391
|
});
|
|
6294
6392
|
|
|
6295
|
-
// node_modules/.bun/@hasna+events@0.1.
|
|
6393
|
+
// node_modules/.bun/@hasna+events@0.1.17/node_modules/@hasna/events/dist/index.js
|
|
6296
6394
|
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
6297
6395
|
import { Buffer as Buffer2 } from "buffer";
|
|
6396
|
+
import { existsSync as existsSync22 } from "fs";
|
|
6397
|
+
import { join as join32 } from "path";
|
|
6298
6398
|
import { existsSync as existsSync6 } from "fs";
|
|
6299
|
-
import { homedir as
|
|
6300
|
-
import { join as
|
|
6399
|
+
import { homedir as homedir22 } from "os";
|
|
6400
|
+
import { join as join22, resolve as resolve8 } from "path";
|
|
6401
|
+
import { homedir as homedir3 } from "os";
|
|
6402
|
+
import { join as join5 } from "path";
|
|
6301
6403
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
6404
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
6405
|
+
import { isIP } from "net";
|
|
6302
6406
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
6303
6407
|
import { spawn } from "child_process";
|
|
6408
|
+
import { request as nodeHttpRequest } from "http";
|
|
6409
|
+
import { request as nodeHttpsRequest } from "https";
|
|
6304
6410
|
import { randomUUID as randomUUID22 } from "crypto";
|
|
6305
6411
|
function getPathValue(input, path) {
|
|
6306
6412
|
return path.split(".").reduce((value, part) => {
|
|
@@ -6397,8 +6503,93 @@ function channelMatchesEvent(channel, event) {
|
|
|
6397
6503
|
return true;
|
|
6398
6504
|
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
6399
6505
|
}
|
|
6506
|
+
function assertApp(app) {
|
|
6507
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
6508
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
6509
|
+
}
|
|
6510
|
+
if (!APP_SLUG_RE.test(app)) {
|
|
6511
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
6512
|
+
}
|
|
6513
|
+
}
|
|
6514
|
+
function envOf(options) {
|
|
6515
|
+
return options.env ?? process.env;
|
|
6516
|
+
}
|
|
6517
|
+
function envValue(options, kind) {
|
|
6518
|
+
const value = envOf(options)[KIND_ENV[kind]];
|
|
6519
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
6520
|
+
}
|
|
6521
|
+
function isMacOS(platform) {
|
|
6522
|
+
return platform === "darwin";
|
|
6523
|
+
}
|
|
6524
|
+
function baseDir(kind, options) {
|
|
6525
|
+
const override = envValue(options, kind);
|
|
6526
|
+
if (override)
|
|
6527
|
+
return override;
|
|
6528
|
+
const home = options.home ?? homedir3();
|
|
6529
|
+
const platform = options.platform ?? process.platform;
|
|
6530
|
+
if (isMacOS(platform)) {
|
|
6531
|
+
switch (kind) {
|
|
6532
|
+
case "config":
|
|
6533
|
+
case "data":
|
|
6534
|
+
return join5(home, "Library", "Application Support", "Hasna");
|
|
6535
|
+
case "cache":
|
|
6536
|
+
return join5(home, "Library", "Caches", "Hasna");
|
|
6537
|
+
case "state":
|
|
6538
|
+
return join5(home, "Library", "Logs", "Hasna");
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
switch (kind) {
|
|
6542
|
+
case "config":
|
|
6543
|
+
return join5(home, ".config", "hasna");
|
|
6544
|
+
case "data":
|
|
6545
|
+
return join5(home, ".local", "share", "hasna");
|
|
6546
|
+
case "state":
|
|
6547
|
+
return join5(home, ".local", "state", "hasna");
|
|
6548
|
+
case "cache":
|
|
6549
|
+
return join5(home, ".cache", "hasna");
|
|
6550
|
+
}
|
|
6551
|
+
}
|
|
6552
|
+
function resolvePath(kind, options) {
|
|
6553
|
+
assertApp(options.app);
|
|
6554
|
+
const appSegment = options.internal === true ? join5("internal", options.app) : options.app;
|
|
6555
|
+
return join5(baseDir(kind, options), appSegment);
|
|
6556
|
+
}
|
|
6557
|
+
function dataDir2(options) {
|
|
6558
|
+
return resolvePath("data", options);
|
|
6559
|
+
}
|
|
6560
|
+
function effectiveHome2() {
|
|
6561
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir22();
|
|
6562
|
+
}
|
|
6563
|
+
function legacyHomeDir2() {
|
|
6564
|
+
return join22(effectiveHome2(), ".hasna", "events");
|
|
6565
|
+
}
|
|
6566
|
+
function resolverHome2() {
|
|
6567
|
+
return dataDir2({ app: "events", home: effectiveHome2() || undefined });
|
|
6568
|
+
}
|
|
6569
|
+
function adoptResolverHome2(resolved, env2 = process.env) {
|
|
6570
|
+
const dataOverride = env2.HASNA_DATA_HOME;
|
|
6571
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
6572
|
+
return true;
|
|
6573
|
+
return existsSync6(join22(resolved, EVENTS_STORE_SENTINEL_FILE));
|
|
6574
|
+
}
|
|
6575
|
+
function exactEventsHome() {
|
|
6576
|
+
const dir = process.env[HASNA_EVENTS_DIR_ENV];
|
|
6577
|
+
if (dir && dir.trim())
|
|
6578
|
+
return dir.trim();
|
|
6579
|
+
const home = process.env[HASNA_EVENTS_HOME_ENV];
|
|
6580
|
+
if (home && home.trim())
|
|
6581
|
+
return home.trim();
|
|
6582
|
+
return;
|
|
6583
|
+
}
|
|
6584
|
+
function getEventsHome() {
|
|
6585
|
+
const exact = exactEventsHome();
|
|
6586
|
+
if (exact)
|
|
6587
|
+
return resolve8(exact);
|
|
6588
|
+
const resolved = resolverHome2();
|
|
6589
|
+
return adoptResolverHome2(resolved) ? resolve8(resolved) : resolve8(legacyHomeDir2());
|
|
6590
|
+
}
|
|
6400
6591
|
function getEventsDataDir(override) {
|
|
6401
|
-
return override ||
|
|
6592
|
+
return override || getEventsHome();
|
|
6402
6593
|
}
|
|
6403
6594
|
|
|
6404
6595
|
class JsonEventsStore {
|
|
@@ -6407,12 +6598,12 @@ class JsonEventsStore {
|
|
|
6407
6598
|
channelsPath;
|
|
6408
6599
|
eventsPath;
|
|
6409
6600
|
deliveriesPath;
|
|
6410
|
-
constructor(
|
|
6411
|
-
this.dataDir =
|
|
6412
|
-
this.runtime = localJsonRuntime(
|
|
6413
|
-
this.channelsPath =
|
|
6414
|
-
this.eventsPath =
|
|
6415
|
-
this.deliveriesPath =
|
|
6601
|
+
constructor(dataDir22 = getEventsDataDir()) {
|
|
6602
|
+
this.dataDir = dataDir22;
|
|
6603
|
+
this.runtime = localJsonRuntime(dataDir22);
|
|
6604
|
+
this.channelsPath = join32(dataDir22, "channels.json");
|
|
6605
|
+
this.eventsPath = join32(dataDir22, "events.json");
|
|
6606
|
+
this.deliveriesPath = join32(dataDir22, "deliveries.json");
|
|
6416
6607
|
}
|
|
6417
6608
|
async init() {
|
|
6418
6609
|
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
@@ -6529,7 +6720,7 @@ class JsonEventsStore {
|
|
|
6529
6720
|
};
|
|
6530
6721
|
}
|
|
6531
6722
|
async ensureArrayFile(path) {
|
|
6532
|
-
if (!
|
|
6723
|
+
if (!existsSync22(path)) {
|
|
6533
6724
|
await writeFile(path, `[]
|
|
6534
6725
|
`, { encoding: "utf-8", mode: 384 });
|
|
6535
6726
|
}
|
|
@@ -6559,7 +6750,7 @@ class JsonEventsStore {
|
|
|
6559
6750
|
});
|
|
6560
6751
|
}
|
|
6561
6752
|
}
|
|
6562
|
-
function localJsonRuntime(
|
|
6753
|
+
function localJsonRuntime(dataDir22 = getEventsDataDir()) {
|
|
6563
6754
|
return {
|
|
6564
6755
|
mode: "local-files",
|
|
6565
6756
|
name: "json-events-store",
|
|
@@ -6572,7 +6763,7 @@ function localJsonRuntime(dataDir2 = getEventsDataDir()) {
|
|
|
6572
6763
|
durable: true,
|
|
6573
6764
|
idempotency: "best-effort-local",
|
|
6574
6765
|
replayCursors: true,
|
|
6575
|
-
description: `Local JSON files in ${
|
|
6766
|
+
description: `Local JSON files in ${dataDir22}; no SQLite, Postgres, S3, or AWS runtime is configured by this store.`
|
|
6576
6767
|
};
|
|
6577
6768
|
}
|
|
6578
6769
|
function encodeLocalJsonEventCursor(offset, options = {}) {
|
|
@@ -6643,6 +6834,177 @@ function signPayload(secret, timestamp2, body) {
|
|
|
6643
6834
|
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp2, body)).digest("hex");
|
|
6644
6835
|
return `sha256=${digest}`;
|
|
6645
6836
|
}
|
|
6837
|
+
function isPrivateAddress(address) {
|
|
6838
|
+
const normalized = stripZoneId(address);
|
|
6839
|
+
const version = isIP(normalized);
|
|
6840
|
+
if (version === 4) {
|
|
6841
|
+
const integer = ipv4ToInt(normalized);
|
|
6842
|
+
if (integer === undefined)
|
|
6843
|
+
return true;
|
|
6844
|
+
return IPV4_PRIVATE_RANGES.some(([low, high]) => integer >= low && integer <= high);
|
|
6845
|
+
}
|
|
6846
|
+
if (version === 6) {
|
|
6847
|
+
const groups = ipv6Groups(normalized);
|
|
6848
|
+
if (!groups)
|
|
6849
|
+
return true;
|
|
6850
|
+
for (const prefix of IPV6_SPECIAL_PREFIXES) {
|
|
6851
|
+
if (!ipv6MatchesPrefix(groups, prefix.groups, prefix.bits))
|
|
6852
|
+
continue;
|
|
6853
|
+
if (prefix.bits === 96 && groups[5] === 65535) {
|
|
6854
|
+
return isPrivateAddress(ipv4IntToString(groups[6] << 16 | groups[7]));
|
|
6855
|
+
}
|
|
6856
|
+
if (prefix.bits === 16 && groups[0] === 8194) {
|
|
6857
|
+
return isPrivateAddress(ipv4IntToString(groups[1] << 16 | groups[2]));
|
|
6858
|
+
}
|
|
6859
|
+
return true;
|
|
6860
|
+
}
|
|
6861
|
+
return false;
|
|
6862
|
+
}
|
|
6863
|
+
return true;
|
|
6864
|
+
}
|
|
6865
|
+
async function resolveWebhookTarget(url, policy = {}) {
|
|
6866
|
+
const hostname = normalizeHostname(url.hostname);
|
|
6867
|
+
const allowlist = (policy.allowPrivateHosts ?? []).map((entry) => normalizeHostname(entry.toLowerCase()));
|
|
6868
|
+
if (allowlist.includes(hostname)) {
|
|
6869
|
+
const version2 = isIP(hostname);
|
|
6870
|
+
if (version2 === 4 || version2 === 6) {
|
|
6871
|
+
return { hostname, addresses: [hostname] };
|
|
6872
|
+
}
|
|
6873
|
+
const lookup2 = policy.lookup ?? defaultTargetLookup;
|
|
6874
|
+
let resolved2;
|
|
6875
|
+
try {
|
|
6876
|
+
resolved2 = await lookup2(hostname);
|
|
6877
|
+
} catch {
|
|
6878
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
6879
|
+
}
|
|
6880
|
+
if (!Array.isArray(resolved2) || resolved2.length === 0) {
|
|
6881
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
6882
|
+
}
|
|
6883
|
+
const addresses = resolved2.map((entry) => normalizeHostname(entry.address));
|
|
6884
|
+
return { hostname, addresses };
|
|
6885
|
+
}
|
|
6886
|
+
const version = isIP(hostname);
|
|
6887
|
+
if (version === 4 || version === 6) {
|
|
6888
|
+
if (isPrivateAddress(hostname)) {
|
|
6889
|
+
throw new Error(`Webhook target ${hostname} is a private or special-use address`);
|
|
6890
|
+
}
|
|
6891
|
+
return { hostname, addresses: [hostname] };
|
|
6892
|
+
}
|
|
6893
|
+
const lookup = policy.lookup ?? defaultTargetLookup;
|
|
6894
|
+
let resolved;
|
|
6895
|
+
try {
|
|
6896
|
+
resolved = await lookup(hostname);
|
|
6897
|
+
} catch {
|
|
6898
|
+
throw new Error(`Webhook target ${hostname} could not be resolved`);
|
|
6899
|
+
}
|
|
6900
|
+
if (!Array.isArray(resolved) || resolved.length === 0) {
|
|
6901
|
+
throw new Error(`Webhook target ${hostname} resolved to no addresses`);
|
|
6902
|
+
}
|
|
6903
|
+
const allowed = [];
|
|
6904
|
+
for (const entry of resolved) {
|
|
6905
|
+
const address = normalizeHostname(entry.address);
|
|
6906
|
+
if (isPrivateAddress(address)) {
|
|
6907
|
+
if (allowlist.includes(address)) {
|
|
6908
|
+
allowed.push(address);
|
|
6909
|
+
continue;
|
|
6910
|
+
}
|
|
6911
|
+
throw new Error(`Webhook target ${hostname} resolves to private or special-use address ${address}`);
|
|
6912
|
+
}
|
|
6913
|
+
allowed.push(address);
|
|
6914
|
+
}
|
|
6915
|
+
if (allowed.length === 0) {
|
|
6916
|
+
throw new Error(`Webhook target ${hostname} resolved to no public addresses`);
|
|
6917
|
+
}
|
|
6918
|
+
return { hostname, addresses: allowed };
|
|
6919
|
+
}
|
|
6920
|
+
function normalizeMaxRedirects(value) {
|
|
6921
|
+
if (value === undefined)
|
|
6922
|
+
return DEFAULT_MAX_REDIRECTS;
|
|
6923
|
+
if (!Number.isInteger(value) || value < 0)
|
|
6924
|
+
throw new Error("webhookTargetPolicy.maxRedirects must be a non-negative integer");
|
|
6925
|
+
return value;
|
|
6926
|
+
}
|
|
6927
|
+
function normalizeHostname(hostname) {
|
|
6928
|
+
const lower = hostname.toLowerCase();
|
|
6929
|
+
if (lower.startsWith("[") && lower.endsWith("]"))
|
|
6930
|
+
return lower.slice(1, -1);
|
|
6931
|
+
return lower;
|
|
6932
|
+
}
|
|
6933
|
+
function stripZoneId(address) {
|
|
6934
|
+
const percent = address.indexOf("%");
|
|
6935
|
+
return percent === -1 ? address : address.slice(0, percent);
|
|
6936
|
+
}
|
|
6937
|
+
function ipv4ToInt(address) {
|
|
6938
|
+
const parts = address.split(".");
|
|
6939
|
+
if (parts.length !== 4)
|
|
6940
|
+
return;
|
|
6941
|
+
let value = 0;
|
|
6942
|
+
for (const part of parts) {
|
|
6943
|
+
if (!/^\d{1,3}$/.test(part))
|
|
6944
|
+
return;
|
|
6945
|
+
const octet = Number(part);
|
|
6946
|
+
if (octet > 255)
|
|
6947
|
+
return;
|
|
6948
|
+
value = value << 8 | octet;
|
|
6949
|
+
}
|
|
6950
|
+
return value >>> 0;
|
|
6951
|
+
}
|
|
6952
|
+
function ipv4IntToString(integer) {
|
|
6953
|
+
return [
|
|
6954
|
+
integer >>> 24 & 255,
|
|
6955
|
+
integer >>> 16 & 255,
|
|
6956
|
+
integer >>> 8 & 255,
|
|
6957
|
+
integer & 255
|
|
6958
|
+
].join(".");
|
|
6959
|
+
}
|
|
6960
|
+
function ipv6Groups(address) {
|
|
6961
|
+
const raw = stripZoneId(address);
|
|
6962
|
+
const doubleColon = raw.indexOf("::");
|
|
6963
|
+
const headText = doubleColon === -1 ? raw : raw.slice(0, doubleColon);
|
|
6964
|
+
const tailText = doubleColon === -1 ? "" : raw.slice(doubleColon + 2);
|
|
6965
|
+
const parseGroups = (text) => {
|
|
6966
|
+
if (text === "")
|
|
6967
|
+
return [];
|
|
6968
|
+
const out = [];
|
|
6969
|
+
for (const part of text.split(":")) {
|
|
6970
|
+
if (part.includes(".")) {
|
|
6971
|
+
const v4 = ipv4ToInt(part);
|
|
6972
|
+
if (v4 === undefined)
|
|
6973
|
+
return;
|
|
6974
|
+
out.push(v4 >>> 16 & 65535, v4 & 65535);
|
|
6975
|
+
} else {
|
|
6976
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(part))
|
|
6977
|
+
return;
|
|
6978
|
+
out.push(parseInt(part, 16));
|
|
6979
|
+
}
|
|
6980
|
+
}
|
|
6981
|
+
return out;
|
|
6982
|
+
};
|
|
6983
|
+
const head = parseGroups(headText);
|
|
6984
|
+
if (!head)
|
|
6985
|
+
return;
|
|
6986
|
+
const tail = parseGroups(tailText);
|
|
6987
|
+
if (!tail)
|
|
6988
|
+
return;
|
|
6989
|
+
const total = head.length + tail.length;
|
|
6990
|
+
if (doubleColon === -1) {
|
|
6991
|
+
return total === 8 ? head : undefined;
|
|
6992
|
+
}
|
|
6993
|
+
if (total >= 8)
|
|
6994
|
+
return;
|
|
6995
|
+
return [...head, ...new Array(8 - total).fill(0), ...tail];
|
|
6996
|
+
}
|
|
6997
|
+
function ipv6MatchesPrefix(groups, prefixGroups, prefixBits) {
|
|
6998
|
+
let remaining = prefixBits;
|
|
6999
|
+
for (let index = 0;index < prefixGroups.length && remaining > 0; index += 1) {
|
|
7000
|
+
const take = Math.min(16, remaining);
|
|
7001
|
+
const mask = 65535 << 16 - take & 65535;
|
|
7002
|
+
if ((groups[index] & mask) !== (prefixGroups[index] & mask))
|
|
7003
|
+
return false;
|
|
7004
|
+
remaining -= take;
|
|
7005
|
+
}
|
|
7006
|
+
return true;
|
|
7007
|
+
}
|
|
6646
7008
|
function now2() {
|
|
6647
7009
|
return new Date().toISOString();
|
|
6648
7010
|
}
|
|
@@ -6673,9 +7035,18 @@ function buildWebhookRequest(event, channel, options = {}) {
|
|
|
6673
7035
|
}
|
|
6674
7036
|
return { body, headers };
|
|
6675
7037
|
}
|
|
7038
|
+
function normalizeWebhookUrl(raw) {
|
|
7039
|
+
const url = new URL(raw);
|
|
7040
|
+
if (url.username !== "" || url.password !== "") {
|
|
7041
|
+
url.username = "";
|
|
7042
|
+
url.password = "";
|
|
7043
|
+
}
|
|
7044
|
+
return url.toString();
|
|
7045
|
+
}
|
|
6676
7046
|
async function dispatchWebhook(event, channel, options = {}) {
|
|
6677
7047
|
if (!channel.webhook)
|
|
6678
7048
|
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7049
|
+
const webhookUrl = normalizeWebhookUrl(channel.webhook.url);
|
|
6679
7050
|
const startedAt = now2();
|
|
6680
7051
|
let secret = channel.webhook.secret;
|
|
6681
7052
|
if (channel.webhook.secretRef) {
|
|
@@ -6692,10 +7063,14 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6692
7063
|
}
|
|
6693
7064
|
const timestamp2 = (options.now?.() ?? new Date).toISOString();
|
|
6694
7065
|
const { body, headers } = buildWebhookRequest(event, channel, { secret, timestamp: timestamp2 });
|
|
7066
|
+
const validateTargets = options.webhookTargetPolicy !== undefined || options.fetchImpl === undefined;
|
|
7067
|
+
if (validateTargets) {
|
|
7068
|
+
return dispatchValidatedWebhook(event, channel, { body, headers, startedAt, options });
|
|
7069
|
+
}
|
|
6695
7070
|
const controller = new AbortController;
|
|
6696
7071
|
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
6697
7072
|
try {
|
|
6698
|
-
const response = await (options.fetchImpl ?? fetch)(
|
|
7073
|
+
const response = await (options.fetchImpl ?? fetch)(webhookUrl, {
|
|
6699
7074
|
method: "POST",
|
|
6700
7075
|
headers,
|
|
6701
7076
|
body,
|
|
@@ -6723,6 +7098,130 @@ async function dispatchWebhook(event, channel, options = {}) {
|
|
|
6723
7098
|
clearTimeout(timeout);
|
|
6724
7099
|
}
|
|
6725
7100
|
}
|
|
7101
|
+
function isRedirectStatus(status) {
|
|
7102
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
7103
|
+
}
|
|
7104
|
+
function redirectKeepsBody(status) {
|
|
7105
|
+
return status === 307 || status === 308;
|
|
7106
|
+
}
|
|
7107
|
+
async function pinnedNativeRequest(target, addresses, method, headers, body, signal, tls) {
|
|
7108
|
+
const isHttps = target.protocol === "https:";
|
|
7109
|
+
if (!isHttps && target.protocol !== "http:") {
|
|
7110
|
+
throw new Error(`Webhook target uses unsupported protocol ${target.protocol}`);
|
|
7111
|
+
}
|
|
7112
|
+
const defaultPort = isHttps ? 443 : 80;
|
|
7113
|
+
const port = target.port ? Number(target.port) : defaultPort;
|
|
7114
|
+
const requestOptions = {
|
|
7115
|
+
hostname: target.hostname,
|
|
7116
|
+
port,
|
|
7117
|
+
path: `${target.pathname}${target.search}`,
|
|
7118
|
+
method,
|
|
7119
|
+
headers,
|
|
7120
|
+
...tls?.ca ? { ca: tls.ca } : {},
|
|
7121
|
+
lookup: (hostname, _options, callback) => {
|
|
7122
|
+
const entries = addresses.map((address) => ({
|
|
7123
|
+
address,
|
|
7124
|
+
family: address.includes(":") ? 6 : 4
|
|
7125
|
+
}));
|
|
7126
|
+
callback(null, entries);
|
|
7127
|
+
}
|
|
7128
|
+
};
|
|
7129
|
+
return new Promise((resolve22, reject) => {
|
|
7130
|
+
const request = isHttps ? nodeHttpsRequest(requestOptions, onResponse) : nodeHttpRequest(requestOptions, onResponse);
|
|
7131
|
+
const onAbort = () => {
|
|
7132
|
+
const error = new Error("The operation was aborted.");
|
|
7133
|
+
error.name = "AbortError";
|
|
7134
|
+
request.destroy(error);
|
|
7135
|
+
};
|
|
7136
|
+
if (signal.aborted)
|
|
7137
|
+
onAbort();
|
|
7138
|
+
else
|
|
7139
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
7140
|
+
request.on("error", reject);
|
|
7141
|
+
if (body !== undefined)
|
|
7142
|
+
request.write(body);
|
|
7143
|
+
request.end();
|
|
7144
|
+
function onResponse(response) {
|
|
7145
|
+
const chunks = [];
|
|
7146
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
7147
|
+
response.on("error", reject);
|
|
7148
|
+
response.on("end", () => {
|
|
7149
|
+
const headersRecord = {};
|
|
7150
|
+
for (const [name, value] of Object.entries(response.headers)) {
|
|
7151
|
+
if (typeof value === "string")
|
|
7152
|
+
headersRecord[name] = value;
|
|
7153
|
+
else if (Array.isArray(value))
|
|
7154
|
+
headersRecord[name] = value.join(", ");
|
|
7155
|
+
}
|
|
7156
|
+
resolve22(new Response(Buffer.concat(chunks), { status: response.statusCode ?? 200, headers: headersRecord }));
|
|
7157
|
+
});
|
|
7158
|
+
}
|
|
7159
|
+
});
|
|
7160
|
+
}
|
|
7161
|
+
async function dispatchValidatedWebhook(event, channel, input) {
|
|
7162
|
+
const { body, headers, startedAt, options } = input;
|
|
7163
|
+
const webhook = channel.webhook;
|
|
7164
|
+
if (!webhook)
|
|
7165
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
7166
|
+
const policy = options.webhookTargetPolicy ?? {};
|
|
7167
|
+
const maxRedirects = normalizeMaxRedirects(policy.maxRedirects);
|
|
7168
|
+
const controller = new AbortController;
|
|
7169
|
+
const timeout = setTimeout(() => controller.abort(), webhook.timeoutMs ?? 15000);
|
|
7170
|
+
try {
|
|
7171
|
+
let target = new URL(normalizeWebhookUrl(webhook.url));
|
|
7172
|
+
let requestHeaders = headers;
|
|
7173
|
+
let method = "POST";
|
|
7174
|
+
let requestBody = body;
|
|
7175
|
+
let redirectsFollowed = 0;
|
|
7176
|
+
for (;; ) {
|
|
7177
|
+
const resolved = await resolveWebhookTarget(target, policy).catch((error) => {
|
|
7178
|
+
throw new Error(`Webhook target rejected by SSRF guard: ${error.message}`);
|
|
7179
|
+
});
|
|
7180
|
+
const response = options.fetchImpl ? await options.fetchImpl(target, {
|
|
7181
|
+
method,
|
|
7182
|
+
headers: requestHeaders,
|
|
7183
|
+
body: requestBody,
|
|
7184
|
+
signal: controller.signal,
|
|
7185
|
+
redirect: "manual"
|
|
7186
|
+
}) : await pinnedNativeRequest(target, resolved.addresses, method, requestHeaders, requestBody, controller.signal, options.tls);
|
|
7187
|
+
const location = response.headers.get("location");
|
|
7188
|
+
if (isRedirectStatus(response.status) && location) {
|
|
7189
|
+
if (redirectsFollowed >= maxRedirects) {
|
|
7190
|
+
return failedAttempt(startedAt, `Webhook target exceeded ${maxRedirects} redirects`);
|
|
7191
|
+
}
|
|
7192
|
+
redirectsFollowed += 1;
|
|
7193
|
+
const next = new URL(location, target);
|
|
7194
|
+
target = next;
|
|
7195
|
+
if (!redirectKeepsBody(response.status)) {
|
|
7196
|
+
method = "GET";
|
|
7197
|
+
requestBody = undefined;
|
|
7198
|
+
requestHeaders = Object.fromEntries(Object.entries(requestHeaders).filter(([name]) => name.toLowerCase() !== "content-type" && name.toLowerCase() !== "content-length"));
|
|
7199
|
+
}
|
|
7200
|
+
continue;
|
|
7201
|
+
}
|
|
7202
|
+
const responseBody = truncate(await response.text());
|
|
7203
|
+
return {
|
|
7204
|
+
attempt: 1,
|
|
7205
|
+
status: response.ok ? "success" : "failed",
|
|
7206
|
+
startedAt,
|
|
7207
|
+
completedAt: now2(),
|
|
7208
|
+
responseStatus: response.status,
|
|
7209
|
+
responseBody,
|
|
7210
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
7211
|
+
};
|
|
7212
|
+
}
|
|
7213
|
+
} catch (error) {
|
|
7214
|
+
return {
|
|
7215
|
+
attempt: 1,
|
|
7216
|
+
status: "failed",
|
|
7217
|
+
startedAt,
|
|
7218
|
+
completedAt: now2(),
|
|
7219
|
+
error: error instanceof Error ? error.message : String(error)
|
|
7220
|
+
};
|
|
7221
|
+
} finally {
|
|
7222
|
+
clearTimeout(timeout);
|
|
7223
|
+
}
|
|
7224
|
+
}
|
|
6726
7225
|
function failedAttempt(startedAt, error) {
|
|
6727
7226
|
return {
|
|
6728
7227
|
attempt: 1,
|
|
@@ -6737,7 +7236,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6737
7236
|
throw new Error(`Channel ${channel.id} has no command config`);
|
|
6738
7237
|
const startedAt = now2();
|
|
6739
7238
|
const eventJson = JSON.stringify(event);
|
|
6740
|
-
const
|
|
7239
|
+
const env2 = {
|
|
6741
7240
|
...process.env,
|
|
6742
7241
|
...channel.command.env,
|
|
6743
7242
|
HASNA_CHANNEL_ID: channel.id,
|
|
@@ -6751,10 +7250,10 @@ async function dispatchCommand(event, channel) {
|
|
|
6751
7250
|
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
6752
7251
|
HASNA_EVENT_JSON: eventJson
|
|
6753
7252
|
};
|
|
6754
|
-
return new Promise((
|
|
7253
|
+
return new Promise((resolve22) => {
|
|
6755
7254
|
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
6756
7255
|
cwd: channel.command.cwd,
|
|
6757
|
-
env,
|
|
7256
|
+
env: env2,
|
|
6758
7257
|
stdio: ["pipe", "pipe", "pipe"]
|
|
6759
7258
|
});
|
|
6760
7259
|
let stdout = "";
|
|
@@ -6769,7 +7268,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6769
7268
|
});
|
|
6770
7269
|
child.on("error", (error) => {
|
|
6771
7270
|
clearTimeout(timeout);
|
|
6772
|
-
|
|
7271
|
+
resolve22({
|
|
6773
7272
|
attempt: 1,
|
|
6774
7273
|
status: "failed",
|
|
6775
7274
|
startedAt,
|
|
@@ -6782,7 +7281,7 @@ async function dispatchCommand(event, channel) {
|
|
|
6782
7281
|
child.on("close", (code, signal) => {
|
|
6783
7282
|
clearTimeout(timeout);
|
|
6784
7283
|
const success = code === 0;
|
|
6785
|
-
|
|
7284
|
+
resolve22({
|
|
6786
7285
|
attempt: 1,
|
|
6787
7286
|
status: success ? "success" : "failed",
|
|
6788
7287
|
startedAt,
|
|
@@ -6918,7 +7417,9 @@ class EventsClient {
|
|
|
6918
7417
|
this.transportOptions = {
|
|
6919
7418
|
fetchImpl: options.fetchImpl,
|
|
6920
7419
|
secretResolver: options.secretResolver,
|
|
6921
|
-
now: options.now
|
|
7420
|
+
now: options.now,
|
|
7421
|
+
tls: options.tls,
|
|
7422
|
+
webhookTargetPolicy: options.webhookTargetPolicy
|
|
6922
7423
|
};
|
|
6923
7424
|
this.catalog = options.catalog ?? defaultEventTypeCatalog;
|
|
6924
7425
|
this.validateCatalogTypes = options.validateCatalogTypes ?? false;
|
|
@@ -7123,9 +7624,51 @@ function normalizeRetryPolicy(policy) {
|
|
|
7123
7624
|
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
7124
7625
|
};
|
|
7125
7626
|
}
|
|
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
|
-
|
|
7627
|
+
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) => {
|
|
7628
|
+
return dnsLookup(hostname, { all: true, verbatim: false });
|
|
7629
|
+
}, EventValidationError, defaultEventTypeCatalog, APP_EVENT_V1_MAX_DATA_BYTES;
|
|
7630
|
+
var init_dist = __esm(() => {
|
|
7631
|
+
KIND_ENV = {
|
|
7632
|
+
config: "HASNA_CONFIG_HOME",
|
|
7633
|
+
data: "HASNA_DATA_HOME",
|
|
7634
|
+
state: "HASNA_STATE_HOME",
|
|
7635
|
+
cache: "HASNA_CACHE_HOME"
|
|
7636
|
+
};
|
|
7637
|
+
APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7128
7638
|
DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
7639
|
+
IPV4_PRIVATE_RANGES = [
|
|
7640
|
+
[0, 16777215],
|
|
7641
|
+
[167772160, 184549375],
|
|
7642
|
+
[1681915904, 1686110207],
|
|
7643
|
+
[2130706432, 2147483647],
|
|
7644
|
+
[2851995648, 2852061183],
|
|
7645
|
+
[2886729728, 2887778303],
|
|
7646
|
+
[3221225472, 3221225727],
|
|
7647
|
+
[3221225984, 3221226239],
|
|
7648
|
+
[3227017984, 3227018239],
|
|
7649
|
+
[3232235520, 3232301055],
|
|
7650
|
+
[3323068416, 3323199487],
|
|
7651
|
+
[3325256704, 3325256959],
|
|
7652
|
+
[3405803776, 3405804031],
|
|
7653
|
+
[3758096384, 4294967295]
|
|
7654
|
+
];
|
|
7655
|
+
IPV6_SPECIAL_PREFIXES = [
|
|
7656
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 0], bits: 128 },
|
|
7657
|
+
{ groups: [0, 0, 0, 0, 0, 0, 0, 1], bits: 128 },
|
|
7658
|
+
{ groups: [0, 0, 0, 0, 0, 65535, 0, 0], bits: 96 },
|
|
7659
|
+
{ groups: [100, 65435, 0, 0, 0, 0, 0, 0], bits: 96 },
|
|
7660
|
+
{ groups: [256, 0, 0, 0, 0, 0, 0, 0], bits: 64 },
|
|
7661
|
+
{ groups: [8193, 0, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
7662
|
+
{ groups: [8193, 2, 0, 0, 0, 0, 0, 0], bits: 48 },
|
|
7663
|
+
{ groups: [8193, 16, 0, 0, 0, 0, 0, 0], bits: 28 },
|
|
7664
|
+
{ groups: [8193, 3512, 0, 0, 0, 0, 0, 0], bits: 32 },
|
|
7665
|
+
{ groups: [8194, 0, 0, 0, 0, 0, 0, 0], bits: 16 },
|
|
7666
|
+
{ groups: [16383, 0, 0, 0, 0, 0, 0, 0], bits: 20 },
|
|
7667
|
+
{ groups: [64512, 0, 0, 0, 0, 0, 0, 0], bits: 7 },
|
|
7668
|
+
{ groups: [65152, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
7669
|
+
{ groups: [65216, 0, 0, 0, 0, 0, 0, 0], bits: 10 },
|
|
7670
|
+
{ groups: [65280, 0, 0, 0, 0, 0, 0, 0], bits: 8 }
|
|
7671
|
+
];
|
|
7129
7672
|
EventValidationError = class EventValidationError extends Error {
|
|
7130
7673
|
eventType;
|
|
7131
7674
|
issues;
|
|
@@ -7149,6 +7692,8 @@ function rowToTaskList(row) {
|
|
|
7149
7692
|
};
|
|
7150
7693
|
}
|
|
7151
7694
|
function createTaskList(input, db) {
|
|
7695
|
+
if (input.status !== undefined)
|
|
7696
|
+
throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
7152
7697
|
const d = db || getDatabase();
|
|
7153
7698
|
return d.transaction(() => {
|
|
7154
7699
|
const id = uuid();
|
|
@@ -7190,6 +7735,8 @@ function listTaskLists(projectId, db) {
|
|
|
7190
7735
|
return d.query("SELECT * FROM task_lists ORDER BY name").all().map(rowToTaskList);
|
|
7191
7736
|
}
|
|
7192
7737
|
function updateTaskList(id, input, db) {
|
|
7738
|
+
if (input.status !== undefined)
|
|
7739
|
+
throw new Error("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
7193
7740
|
const d = db || getDatabase();
|
|
7194
7741
|
return d.transaction(() => {
|
|
7195
7742
|
const existing = getTaskList(id, d);
|
|
@@ -7547,7 +8094,7 @@ function emitSharedTaskEventQuiet(input) {
|
|
|
7547
8094
|
}
|
|
7548
8095
|
var SOURCE = "todos";
|
|
7549
8096
|
var init_shared_events = __esm(() => {
|
|
7550
|
-
|
|
8097
|
+
init_dist();
|
|
7551
8098
|
init_database();
|
|
7552
8099
|
init_projects();
|
|
7553
8100
|
init_task_lists();
|
|
@@ -11188,7 +11735,13 @@ function resolveCreateSlug(input, projectId, db) {
|
|
|
11188
11735
|
}
|
|
11189
11736
|
return nextPlanSlug(planSlugBase2(input.name), projectId, db);
|
|
11190
11737
|
}
|
|
11738
|
+
function assertLegacyPlanInput(input) {
|
|
11739
|
+
if (input.start_date !== undefined || input.end_date !== undefined || input.status === "planning" || input.status === "cancelled") {
|
|
11740
|
+
throw new Error("Plan calendar dates and planning/cancelled statuses require the shared Todos API; explicit SQLite storage cannot retain them");
|
|
11741
|
+
}
|
|
11742
|
+
}
|
|
11191
11743
|
function createPlan(input, db) {
|
|
11744
|
+
assertLegacyPlanInput(input);
|
|
11192
11745
|
const d = db || getDatabase();
|
|
11193
11746
|
const id = uuid();
|
|
11194
11747
|
const timestamp2 = now();
|
|
@@ -11269,6 +11822,7 @@ function updatePlanStored(id, input, db) {
|
|
|
11269
11822
|
return updated;
|
|
11270
11823
|
}
|
|
11271
11824
|
function updatePlan(id, input, db) {
|
|
11825
|
+
assertLegacyPlanInput(input);
|
|
11272
11826
|
const d = db || getDatabase();
|
|
11273
11827
|
return d.transaction(() => {
|
|
11274
11828
|
guardPlanRowsSqlite([id], d);
|
|
@@ -11743,27 +12297,27 @@ var init_boards = __esm(() => {
|
|
|
11743
12297
|
// src/lib/artifact-store.ts
|
|
11744
12298
|
import { createHash as createHash3 } from "crypto";
|
|
11745
12299
|
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
|
|
12300
|
+
import { basename, dirname as dirname4, join as join6, resolve as resolve9 } from "path";
|
|
11747
12301
|
import { tmpdir as tmpdir2 } from "os";
|
|
11748
12302
|
function isInMemoryDb2(path) {
|
|
11749
12303
|
return path === ":memory:" || path.startsWith("file::memory:");
|
|
11750
12304
|
}
|
|
11751
12305
|
function artifactStoreRoot() {
|
|
11752
12306
|
if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
|
|
11753
|
-
return
|
|
12307
|
+
return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
|
|
11754
12308
|
if (process.env["TODOS_ARTIFACTS_DIR"])
|
|
11755
|
-
return
|
|
12309
|
+
return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
|
|
11756
12310
|
const dbPath = getDatabasePath();
|
|
11757
12311
|
if (isInMemoryDb2(dbPath))
|
|
11758
|
-
return
|
|
11759
|
-
return
|
|
12312
|
+
return join6(tmpdir2(), "hasna-todos-artifacts");
|
|
12313
|
+
return join6(dirname4(resolve9(dbPath)), "artifacts");
|
|
11760
12314
|
}
|
|
11761
12315
|
function artifactStorePath(relativePath) {
|
|
11762
12316
|
const normalized = relativePath.replace(/\\/g, "/");
|
|
11763
12317
|
if (normalized.includes("..") || normalized.startsWith("/") || normalized.length === 0) {
|
|
11764
12318
|
throw new Error("Invalid artifact store path");
|
|
11765
12319
|
}
|
|
11766
|
-
return
|
|
12320
|
+
return join6(artifactStoreRoot(), normalized);
|
|
11767
12321
|
}
|
|
11768
12322
|
function sha256(buffer) {
|
|
11769
12323
|
return createHash3("sha256").update(buffer).digest("hex");
|
|
@@ -11803,7 +12357,7 @@ function mediaTypeFor(path, textLike) {
|
|
|
11803
12357
|
return "application/octet-stream";
|
|
11804
12358
|
}
|
|
11805
12359
|
function storeArtifactContent(input) {
|
|
11806
|
-
const sourcePath =
|
|
12360
|
+
const sourcePath = resolve9(input.path);
|
|
11807
12361
|
if (!existsSync8(sourcePath))
|
|
11808
12362
|
return null;
|
|
11809
12363
|
const sourceStat = statSync2(sourcePath);
|
|
@@ -11821,7 +12375,7 @@ function storeArtifactContent(input) {
|
|
|
11821
12375
|
redactionStatus = "redacted";
|
|
11822
12376
|
}
|
|
11823
12377
|
const storedSha = sha256(storedBuffer);
|
|
11824
|
-
const relativePath =
|
|
12378
|
+
const relativePath = join6("sha256", storedSha.slice(0, 2), storedSha).replace(/\\/g, "/");
|
|
11825
12379
|
const destination = artifactStorePath(relativePath);
|
|
11826
12380
|
if (!existsSync8(destination)) {
|
|
11827
12381
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
@@ -11958,19 +12512,19 @@ function importStoredArtifactContent(content) {
|
|
|
11958
12512
|
}
|
|
11959
12513
|
function getArtifactStoreRoot(dbPath) {
|
|
11960
12514
|
if (process.env["HASNA_TODOS_ARTIFACTS_DIR"])
|
|
11961
|
-
return
|
|
12515
|
+
return resolve9(process.env["HASNA_TODOS_ARTIFACTS_DIR"]);
|
|
11962
12516
|
if (process.env["TODOS_ARTIFACTS_DIR"])
|
|
11963
|
-
return
|
|
12517
|
+
return resolve9(process.env["TODOS_ARTIFACTS_DIR"]);
|
|
11964
12518
|
const path = dbPath ?? getDatabasePath();
|
|
11965
12519
|
if (isInMemoryDb2(path))
|
|
11966
|
-
return
|
|
11967
|
-
return
|
|
12520
|
+
return join6(tmpdir2(), "hasna-todos-artifacts");
|
|
12521
|
+
return join6(dirname4(resolve9(path)), "artifacts");
|
|
11968
12522
|
}
|
|
11969
12523
|
function computeContentHash(path) {
|
|
11970
|
-
return sha256(readFileSync4(
|
|
12524
|
+
return sha256(readFileSync4(resolve9(path)));
|
|
11971
12525
|
}
|
|
11972
12526
|
function storeArtifactFile(input) {
|
|
11973
|
-
const sourcePath =
|
|
12527
|
+
const sourcePath = resolve9(input.sourcePath);
|
|
11974
12528
|
if (!existsSync8(sourcePath)) {
|
|
11975
12529
|
throw new Error(`Source file not found: ${input.sourcePath}`);
|
|
11976
12530
|
}
|
|
@@ -11984,7 +12538,7 @@ function storeArtifactFile(input) {
|
|
|
11984
12538
|
let localPath = sourcePath;
|
|
11985
12539
|
if (storageMode === "copy") {
|
|
11986
12540
|
const fileName = input.name && input.name.trim().length > 0 ? basename(input.name) : basename(sourcePath);
|
|
11987
|
-
const destination =
|
|
12541
|
+
const destination = join6(getArtifactStoreRoot(input.dbPath), input.artifactId, fileName);
|
|
11988
12542
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
11989
12543
|
writeFileSync2(destination, buffer);
|
|
11990
12544
|
localPath = destination;
|
|
@@ -12019,7 +12573,7 @@ function buildArtifactExportManifest(artifacts, dbPath) {
|
|
|
12019
12573
|
};
|
|
12020
12574
|
}
|
|
12021
12575
|
function writeArtifactExportManifest(manifest, outputPath) {
|
|
12022
|
-
const destination =
|
|
12576
|
+
const destination = resolve9(outputPath);
|
|
12023
12577
|
mkdirSync4(dirname4(destination), { recursive: true });
|
|
12024
12578
|
writeFileSync2(destination, `${JSON.stringify(manifest, null, 2)}
|
|
12025
12579
|
`);
|
|
@@ -12707,7 +13261,9 @@ function finishTaskRunTransaction(input, db) {
|
|
|
12707
13261
|
const key = input.key ? normalizeTransactionKey({ key: input.key }) : "";
|
|
12708
13262
|
const run = input.run_id ? getTaskRun(resolveTaskRunId(input.run_id, d), d) : key ? findTaskRunByTransactionKey(key, input.task_id, d) : null;
|
|
12709
13263
|
if (!run) {
|
|
12710
|
-
|
|
13264
|
+
if (input.run_id)
|
|
13265
|
+
throw new InputValidationError(`Run not found: ${input.run_id}`, "Pass an existing run id, or --key.");
|
|
13266
|
+
throw new InputValidationError("runs finish requires a run id or --key", "Pass run_id, or key with task_id.");
|
|
12711
13267
|
}
|
|
12712
13268
|
if (input.task_id && run.task_id !== input.task_id) {
|
|
12713
13269
|
throw new Error(`Run ${run.id} belongs to task ${run.task_id}, not ${input.task_id}`);
|
|
@@ -13692,9 +14248,9 @@ function normalizeGeneratedAgentNames(db) {
|
|
|
13692
14248
|
|
|
13693
14249
|
// src/db/agents.ts
|
|
13694
14250
|
function getActiveWindowMs() {
|
|
13695
|
-
const
|
|
13696
|
-
if (
|
|
13697
|
-
const parsed = parseInt(
|
|
14251
|
+
const env2 = process.env["TODOS_AGENT_TIMEOUT_MS"];
|
|
14252
|
+
if (env2) {
|
|
14253
|
+
const parsed = parseInt(env2, 10);
|
|
13698
14254
|
if (!isNaN(parsed) && parsed > 0)
|
|
13699
14255
|
return parsed;
|
|
13700
14256
|
}
|
|
@@ -14540,19 +15096,184 @@ function rollbackPlanProjectLinkSqlite(input, db) {
|
|
|
14540
15096
|
})();
|
|
14541
15097
|
}
|
|
14542
15098
|
|
|
14543
|
-
// src/storage/
|
|
14544
|
-
|
|
14545
|
-
|
|
14546
|
-
|
|
14547
|
-
|
|
14548
|
-
|
|
14549
|
-
|
|
14550
|
-
|
|
14551
|
-
|
|
14552
|
-
|
|
14553
|
-
|
|
14554
|
-
|
|
14555
|
-
|
|
15099
|
+
// src/storage/machine-registry.ts
|
|
15100
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
15101
|
+
import { isDeepStrictEqual } from "util";
|
|
15102
|
+
class MachineRegistryError extends Error {
|
|
15103
|
+
status;
|
|
15104
|
+
constructor(message, status = 409) {
|
|
15105
|
+
super(message);
|
|
15106
|
+
this.status = status;
|
|
15107
|
+
}
|
|
15108
|
+
}
|
|
15109
|
+
var fields = new Set(["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"]);
|
|
15110
|
+
function validateMachines(value) {
|
|
15111
|
+
if (!Array.isArray(value) || value.length > 1e4)
|
|
15112
|
+
throw new MachineRegistryError("machines must be an array of at most 10000 complete records", 400);
|
|
15113
|
+
for (const row of value) {
|
|
15114
|
+
if (!row || typeof row !== "object" || Array.isArray(row) || Object.keys(row).some((key) => !fields.has(key)))
|
|
15115
|
+
throw new MachineRegistryError("Invalid machine record fields", 400);
|
|
15116
|
+
for (const key of ["id", "name", "last_seen_at", "created_at"])
|
|
15117
|
+
if (typeof row[key] !== "string" || !row[key].trim() || row[key].length > 1024)
|
|
15118
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15119
|
+
for (const key of ["hostname", "platform", "ssh_address", "archived_at"])
|
|
15120
|
+
if (row[key] !== null && typeof row[key] !== "string")
|
|
15121
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15122
|
+
for (const key of ["last_seen_at", "created_at", "archived_at"])
|
|
15123
|
+
if (row[key] !== null && !Number.isFinite(Date.parse(row[key])))
|
|
15124
|
+
throw new MachineRegistryError(`Invalid machine ${key}`, 400);
|
|
15125
|
+
if (typeof row.is_primary !== "boolean" || !row.metadata || typeof row.metadata !== "object" || Array.isArray(row.metadata))
|
|
15126
|
+
throw new MachineRegistryError("Invalid machine flags or metadata", 400);
|
|
15127
|
+
if (!isDeepStrictEqual(JSON.parse(JSON.stringify(row.metadata)), row.metadata))
|
|
15128
|
+
throw new MachineRegistryError("Machine metadata must preserve JSON values exactly", 400);
|
|
15129
|
+
if (JSON.stringify(row.metadata).length > 262144)
|
|
15130
|
+
throw new MachineRegistryError("Machine metadata is too large", 400);
|
|
15131
|
+
if (row.is_primary && row.archived_at !== null)
|
|
15132
|
+
throw new MachineRegistryError("An archived machine cannot be primary", 400);
|
|
15133
|
+
}
|
|
15134
|
+
return value;
|
|
15135
|
+
}
|
|
15136
|
+
function planMachineImport(existing, incoming) {
|
|
15137
|
+
const rows = validateMachines(incoming);
|
|
15138
|
+
const byId = new Map(existing.map((row) => [row.id, row]));
|
|
15139
|
+
const byName = new Map(existing.map((row) => [row.name, row.id]));
|
|
15140
|
+
const insert = [];
|
|
15141
|
+
let skipped = 0;
|
|
15142
|
+
for (const row of rows) {
|
|
15143
|
+
const prior = byId.get(row.id);
|
|
15144
|
+
if (prior && !isDeepStrictEqual(prior, row))
|
|
15145
|
+
throw new MachineRegistryError("Machine migration conflicts with an existing identity; reconcile before retrying");
|
|
15146
|
+
if (byName.has(row.name) && byName.get(row.name) !== row.id)
|
|
15147
|
+
throw new MachineRegistryError("Machine name belongs to a different identity; migration was not applied");
|
|
15148
|
+
if (prior)
|
|
15149
|
+
skipped++;
|
|
15150
|
+
else {
|
|
15151
|
+
insert.push(row);
|
|
15152
|
+
byId.set(row.id, row);
|
|
15153
|
+
byName.set(row.name, row.id);
|
|
15154
|
+
}
|
|
15155
|
+
}
|
|
15156
|
+
if ([...byId.values()].filter((row) => row.is_primary).length > 1)
|
|
15157
|
+
throw new MachineRegistryError("Machine migration would create multiple primary machines");
|
|
15158
|
+
return { rows: insert, skipped };
|
|
15159
|
+
}
|
|
15160
|
+
function createPostgresMachineRegistry(client, service, table, ensure) {
|
|
15161
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table))
|
|
15162
|
+
throw new Error("Invalid machine registry table");
|
|
15163
|
+
async function list(connection = client) {
|
|
15164
|
+
await ensure();
|
|
15165
|
+
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]);
|
|
15166
|
+
return validateMachines(result.rows.map((row) => typeof row.payload === "string" ? JSON.parse(row.payload) : row.payload));
|
|
15167
|
+
}
|
|
15168
|
+
return { list, async execute(input) {
|
|
15169
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || !["register", "heartbeat", "set-primary", "archive", "unarchive", "delete", "import"].includes(input.action))
|
|
15170
|
+
throw new MachineRegistryError("Invalid machine operation", 400);
|
|
15171
|
+
if (Object.keys(input).some((key) => !["action", "name", "id", "options", "machines"].includes(key)))
|
|
15172
|
+
throw new MachineRegistryError("Unknown machine operation fields", 400);
|
|
15173
|
+
if (input.options !== undefined && (!input.options || typeof input.options !== "object" || Array.isArray(input.options)))
|
|
15174
|
+
throw new MachineRegistryError("Invalid machine options", 400);
|
|
15175
|
+
await ensure();
|
|
15176
|
+
if (!client.transaction)
|
|
15177
|
+
throw new MachineRegistryError("Machine registry requires a transactional server backend", 501);
|
|
15178
|
+
return client.transaction(async (tx) => {
|
|
15179
|
+
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${service}:machine-registry`]);
|
|
15180
|
+
const current = await list(tx);
|
|
15181
|
+
let changed = [];
|
|
15182
|
+
let skipped = 0;
|
|
15183
|
+
let machine;
|
|
15184
|
+
let deleted;
|
|
15185
|
+
const stamp = new Date().toISOString();
|
|
15186
|
+
if (input.action === "import") {
|
|
15187
|
+
const plan = planMachineImport(current, input.machines);
|
|
15188
|
+
const ids = plan.rows.map((row) => row.id);
|
|
15189
|
+
if (ids.length) {
|
|
15190
|
+
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)]);
|
|
15191
|
+
if (retired.rows.length)
|
|
15192
|
+
throw new MachineRegistryError("Machine migration contains a retired identity");
|
|
15193
|
+
}
|
|
15194
|
+
changed = plan.rows;
|
|
15195
|
+
skipped = plan.skipped;
|
|
15196
|
+
} else {
|
|
15197
|
+
if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 1024)
|
|
15198
|
+
throw new MachineRegistryError("Machine name or ID is required", 400);
|
|
15199
|
+
const matches = current.filter((row) => row.name === input.name || row.id === input.name);
|
|
15200
|
+
if (matches.length > 1)
|
|
15201
|
+
throw new MachineRegistryError("Machine selector is ambiguous between a name and another identity; use an unambiguous selector");
|
|
15202
|
+
const prior = matches[0];
|
|
15203
|
+
if (input.action === "register" || input.action === "heartbeat") {
|
|
15204
|
+
const opts = input.options ?? {};
|
|
15205
|
+
const allowed = new Set(["hostname", "platform", "ssh_address", "primary", "tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"]);
|
|
15206
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
15207
|
+
if (!allowed.has(key) || (key === "primary" ? typeof value !== "boolean" : typeof value !== "string" || value.length > 4096))
|
|
15208
|
+
throw new MachineRegistryError("Invalid machine registration options", 400);
|
|
15209
|
+
}
|
|
15210
|
+
if (input.id !== undefined && (typeof input.id !== "string" || !input.id.trim() || input.id.length > 1024))
|
|
15211
|
+
throw new MachineRegistryError("Invalid stable machine ID", 400);
|
|
15212
|
+
if (prior && input.id && input.id !== prior.id)
|
|
15213
|
+
throw new MachineRegistryError("Machine registration ID does not match existing identity");
|
|
15214
|
+
if (!prior && input.id && current.some((row) => row.id === input.id))
|
|
15215
|
+
throw new MachineRegistryError("Machine ID already belongs to another name");
|
|
15216
|
+
const metadata = { ...prior?.metadata };
|
|
15217
|
+
for (const key of ["tailscale_name", "tailscale_ip", "lan_address", "workspace_path", "git_root", "arch"])
|
|
15218
|
+
if (opts[key] !== undefined)
|
|
15219
|
+
metadata[key] = opts[key];
|
|
15220
|
+
if (!prior && input.id) {
|
|
15221
|
+
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]);
|
|
15222
|
+
if (retired.rows.length)
|
|
15223
|
+
throw new MachineRegistryError("Machine identity has been retired");
|
|
15224
|
+
}
|
|
15225
|
+
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 };
|
|
15226
|
+
if (machine.archived_at && machine.is_primary)
|
|
15227
|
+
throw new MachineRegistryError("Cannot set archived machine as primary");
|
|
15228
|
+
changed = [machine];
|
|
15229
|
+
if (opts.primary === true)
|
|
15230
|
+
changed.push(...current.filter((row) => row.is_primary && row.id !== machine.id).map((row) => ({ ...row, is_primary: false })));
|
|
15231
|
+
} else {
|
|
15232
|
+
if (!prior)
|
|
15233
|
+
throw new MachineRegistryError("Machine not found", 404);
|
|
15234
|
+
machine = { ...prior };
|
|
15235
|
+
if (input.action === "set-primary") {
|
|
15236
|
+
if (prior.archived_at)
|
|
15237
|
+
throw new MachineRegistryError("Cannot set archived machine as primary");
|
|
15238
|
+
machine.is_primary = true;
|
|
15239
|
+
changed = current.filter((row) => row.is_primary && row.id !== prior.id).map((row) => ({ ...row, is_primary: false }));
|
|
15240
|
+
} else if (input.action === "archive" || input.action === "delete") {
|
|
15241
|
+
if (prior.is_primary)
|
|
15242
|
+
throw new MachineRegistryError("Cannot archive or delete the primary machine");
|
|
15243
|
+
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]);
|
|
15244
|
+
if (refs.rows.length)
|
|
15245
|
+
throw new MachineRegistryError("Machine still has referenced records or active tasks");
|
|
15246
|
+
machine.archived_at = stamp;
|
|
15247
|
+
deleted = input.action === "delete" ? true : undefined;
|
|
15248
|
+
} else if (input.action === "unarchive")
|
|
15249
|
+
machine.archived_at = null;
|
|
15250
|
+
else
|
|
15251
|
+
throw new MachineRegistryError("Unknown machine action", 400);
|
|
15252
|
+
changed.push(machine);
|
|
15253
|
+
}
|
|
15254
|
+
}
|
|
15255
|
+
for (const row of changed) {
|
|
15256
|
+
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]);
|
|
15257
|
+
}
|
|
15258
|
+
const machines = await list(tx);
|
|
15259
|
+
return { schema_version: 2, machines, ...machine ? { machine } : {}, inserted: changed.filter((row) => !current.some((old) => old.id === row.id)).length, skipped, ...deleted ? { deleted } : {} };
|
|
15260
|
+
});
|
|
15261
|
+
} };
|
|
15262
|
+
}
|
|
15263
|
+
|
|
15264
|
+
// src/storage/sqlite-snapshot.ts
|
|
15265
|
+
init_database();
|
|
15266
|
+
init_audit();
|
|
15267
|
+
init_plans();
|
|
15268
|
+
init_projects();
|
|
15269
|
+
init_task_lists();
|
|
15270
|
+
init_tasks();
|
|
15271
|
+
init_templates();
|
|
15272
|
+
init_storage_tombstones();
|
|
15273
|
+
|
|
15274
|
+
// src/storage/audit-history-import.ts
|
|
15275
|
+
var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
|
|
15276
|
+
var AUDIT_HISTORY_TOMBSTONE_FORBIDDEN = "AUDIT_HISTORY_TOMBSTONE_FORBIDDEN";
|
|
14556
15277
|
var AUDIT_HISTORY_FIELDS = [
|
|
14557
15278
|
"id",
|
|
14558
15279
|
"task_id",
|
|
@@ -14753,6 +15474,7 @@ function exportSqliteTodosStorageSnapshot(db) {
|
|
|
14753
15474
|
return {
|
|
14754
15475
|
exportedAt: new Date().toISOString(),
|
|
14755
15476
|
source: "sqlite",
|
|
15477
|
+
machines: exportSqliteMachines(d),
|
|
14756
15478
|
tasks: listTasks({ include_archived: true }, d),
|
|
14757
15479
|
projects: listProjects(d),
|
|
14758
15480
|
projectMachinePaths: listProjectMachinePaths(d),
|
|
@@ -14766,7 +15488,6 @@ function exportSqliteTodosStorageSnapshot(db) {
|
|
|
14766
15488
|
};
|
|
14767
15489
|
}
|
|
14768
15490
|
function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
14769
|
-
const d = db ?? getDatabase();
|
|
14770
15491
|
const result = {
|
|
14771
15492
|
inserted: 0,
|
|
14772
15493
|
updated: 0,
|
|
@@ -14774,6 +15495,22 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14774
15495
|
skipped: 0,
|
|
14775
15496
|
errors: []
|
|
14776
15497
|
};
|
|
15498
|
+
if (snapshot.taskLists.some((row) => row.status !== undefined)) {
|
|
15499
|
+
result.errors.push("The explicit SQLite storage cannot retain task-list status; use the shared API");
|
|
15500
|
+
return result;
|
|
15501
|
+
}
|
|
15502
|
+
const d = db ?? getDatabase();
|
|
15503
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
|
|
15504
|
+
result.errors.push("Machine tombstones require explicit registry lifecycle operations");
|
|
15505
|
+
return result;
|
|
15506
|
+
}
|
|
15507
|
+
try {
|
|
15508
|
+
if (snapshot.machines !== undefined)
|
|
15509
|
+
validateMachines(snapshot.machines);
|
|
15510
|
+
} catch (e) {
|
|
15511
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
15512
|
+
return result;
|
|
15513
|
+
}
|
|
14777
15514
|
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
14778
15515
|
if (result.errors.length === 0) {
|
|
14779
15516
|
const existingProjects = d.query("SELECT id, task_list_id FROM projects").all();
|
|
@@ -14784,6 +15521,20 @@ function importSqliteTodosStorageSnapshot(snapshot, db) {
|
|
|
14784
15521
|
result.errors.push(...auditImport.errors);
|
|
14785
15522
|
if (result.errors.length > 0)
|
|
14786
15523
|
return result;
|
|
15524
|
+
if (snapshot.machines?.length) {
|
|
15525
|
+
try {
|
|
15526
|
+
d.transaction(() => {
|
|
15527
|
+
const plan = planMachineImport(exportSqliteMachines(d), snapshot.machines);
|
|
15528
|
+
for (const row of plan.rows)
|
|
15529
|
+
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]);
|
|
15530
|
+
result.inserted += plan.rows.length;
|
|
15531
|
+
result.skipped += plan.skipped;
|
|
15532
|
+
})();
|
|
15533
|
+
} catch (error) {
|
|
15534
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
15535
|
+
return result;
|
|
15536
|
+
}
|
|
15537
|
+
}
|
|
14787
15538
|
result.skipped += auditImport.identicalReplayCount;
|
|
14788
15539
|
const applyRows = (objectType, table, columns, rows, updateClockColumn, acceptEqualClock = true, afterUpsert) => {
|
|
14789
15540
|
for (const row of rows) {
|
|
@@ -15018,6 +15769,17 @@ function clockColumnsForTable(table) {
|
|
|
15018
15769
|
return ["created_at"];
|
|
15019
15770
|
return ["updated_at", "created_at"];
|
|
15020
15771
|
}
|
|
15772
|
+
function exportSqliteMachines(db) {
|
|
15773
|
+
const columns = ["id", "name", "hostname", "platform", "last_seen_at", "metadata", "created_at", "ssh_address", "is_primary", "archived_at"];
|
|
15774
|
+
const observed = db.query("PRAGMA table_info(machines)").all().map((row) => row.name);
|
|
15775
|
+
if (observed.length !== columns.length || observed.some((name) => !columns.includes(name)))
|
|
15776
|
+
throw new Error("Machine SQLite schema differs from the lossless migration contract; preserve the source and upgrade the migrator");
|
|
15777
|
+
return validateMachines(db.query("SELECT * FROM machines ORDER BY id").all().map((row) => {
|
|
15778
|
+
if (row.is_primary !== 0 && row.is_primary !== 1)
|
|
15779
|
+
throw new Error("Machine is_primary must be the raw SQLite integer 0 or 1; migration refused");
|
|
15780
|
+
return { ...row, is_primary: row.is_primary === 1, metadata: JSON.parse(String(row.metadata)) };
|
|
15781
|
+
}));
|
|
15782
|
+
}
|
|
15021
15783
|
|
|
15022
15784
|
// src/storage/local-sqlite.ts
|
|
15023
15785
|
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;
|
|
@@ -15223,9 +15985,1158 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
15223
15985
|
return adapter;
|
|
15224
15986
|
}
|
|
15225
15987
|
|
|
15988
|
+
// src/lib/builtin-template-library.ts
|
|
15989
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
15990
|
+
import { join as join7 } from "path";
|
|
15991
|
+
var BUILTIN_TEMPLATE_LIBRARY_VERSION = "2026-05-21";
|
|
15992
|
+
var BUILTIN_TEMPLATE_LIBRARY_SOURCE = "bundled-local-template-library";
|
|
15993
|
+
var TEMPLATE_LIBRARY_SCHEMA = "todos.template_library.v1";
|
|
15994
|
+
var BUILTIN_TEMPLATES = [
|
|
15995
|
+
{
|
|
15996
|
+
name: "bug-fix",
|
|
15997
|
+
description: "Reproduce, diagnose, fix, test, and release a defect.",
|
|
15998
|
+
category: "bug-fix",
|
|
15999
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16000
|
+
variables: [
|
|
16001
|
+
{ name: "bug", required: true, description: "Bug description" }
|
|
16002
|
+
],
|
|
16003
|
+
tasks: [
|
|
16004
|
+
{
|
|
16005
|
+
position: 0,
|
|
16006
|
+
title_pattern: "Reproduce: {bug}",
|
|
16007
|
+
priority: "critical",
|
|
16008
|
+
tags: ["bug", "repro"]
|
|
16009
|
+
},
|
|
16010
|
+
{
|
|
16011
|
+
position: 1,
|
|
16012
|
+
title_pattern: "Diagnose root cause of {bug}",
|
|
16013
|
+
priority: "critical",
|
|
16014
|
+
tags: ["bug", "diagnosis"],
|
|
16015
|
+
depends_on_positions: [0]
|
|
16016
|
+
},
|
|
16017
|
+
{
|
|
16018
|
+
position: 2,
|
|
16019
|
+
title_pattern: "Write regression test for {bug}",
|
|
16020
|
+
priority: "high",
|
|
16021
|
+
tags: ["bug", "test"],
|
|
16022
|
+
depends_on_positions: [1]
|
|
16023
|
+
},
|
|
16024
|
+
{
|
|
16025
|
+
position: 3,
|
|
16026
|
+
title_pattern: "Implement fix for {bug}",
|
|
16027
|
+
priority: "critical",
|
|
16028
|
+
tags: ["bug", "implementation"],
|
|
16029
|
+
depends_on_positions: [2]
|
|
16030
|
+
},
|
|
16031
|
+
{
|
|
16032
|
+
position: 4,
|
|
16033
|
+
title_pattern: "Run full verification for {bug}",
|
|
16034
|
+
priority: "high",
|
|
16035
|
+
tags: ["bug", "verification"],
|
|
16036
|
+
depends_on_positions: [3]
|
|
16037
|
+
},
|
|
16038
|
+
{
|
|
16039
|
+
position: 5,
|
|
16040
|
+
title_pattern: "Publish and smoke test fix for {bug}",
|
|
16041
|
+
priority: "high",
|
|
16042
|
+
tags: ["bug", "release"],
|
|
16043
|
+
depends_on_positions: [4]
|
|
16044
|
+
}
|
|
16045
|
+
]
|
|
16046
|
+
},
|
|
16047
|
+
{
|
|
16048
|
+
name: "feature-implementation",
|
|
16049
|
+
description: "Plan, build, test, document, and release a product feature.",
|
|
16050
|
+
category: "feature",
|
|
16051
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16052
|
+
variables: [
|
|
16053
|
+
{ name: "feature", required: true, description: "Feature name" },
|
|
16054
|
+
{
|
|
16055
|
+
name: "scope",
|
|
16056
|
+
required: false,
|
|
16057
|
+
default: "medium",
|
|
16058
|
+
description: "Implementation size or risk"
|
|
16059
|
+
}
|
|
16060
|
+
],
|
|
16061
|
+
tasks: [
|
|
16062
|
+
{
|
|
16063
|
+
position: 0,
|
|
16064
|
+
title_pattern: "Define acceptance criteria for {feature}",
|
|
16065
|
+
priority: "high",
|
|
16066
|
+
tags: ["feature", "spec"]
|
|
16067
|
+
},
|
|
16068
|
+
{
|
|
16069
|
+
position: 1,
|
|
16070
|
+
title_pattern: "Design {scope} implementation plan for {feature}",
|
|
16071
|
+
priority: "high",
|
|
16072
|
+
tags: ["feature", "design"],
|
|
16073
|
+
depends_on_positions: [0]
|
|
16074
|
+
},
|
|
16075
|
+
{
|
|
16076
|
+
position: 2,
|
|
16077
|
+
title_pattern: "Implement {feature}",
|
|
16078
|
+
priority: "critical",
|
|
16079
|
+
tags: ["feature", "implementation"],
|
|
16080
|
+
depends_on_positions: [1]
|
|
16081
|
+
},
|
|
16082
|
+
{
|
|
16083
|
+
position: 3,
|
|
16084
|
+
title_pattern: "Add tests for {feature}",
|
|
16085
|
+
priority: "high",
|
|
16086
|
+
tags: ["feature", "test"],
|
|
16087
|
+
depends_on_positions: [2]
|
|
16088
|
+
},
|
|
16089
|
+
{
|
|
16090
|
+
position: 4,
|
|
16091
|
+
title_pattern: "Update docs for {feature}",
|
|
16092
|
+
priority: "medium",
|
|
16093
|
+
tags: ["feature", "docs"],
|
|
16094
|
+
depends_on_positions: [2]
|
|
16095
|
+
},
|
|
16096
|
+
{
|
|
16097
|
+
position: 5,
|
|
16098
|
+
title_pattern: "Run release checks for {feature}",
|
|
16099
|
+
priority: "high",
|
|
16100
|
+
tags: ["feature", "verification"],
|
|
16101
|
+
depends_on_positions: [3, 4]
|
|
16102
|
+
}
|
|
16103
|
+
]
|
|
16104
|
+
},
|
|
16105
|
+
{
|
|
16106
|
+
name: "security-review",
|
|
16107
|
+
description: "Threat model, test, remediate, and report on security posture.",
|
|
16108
|
+
category: "security",
|
|
16109
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16110
|
+
variables: [
|
|
16111
|
+
{
|
|
16112
|
+
name: "target",
|
|
16113
|
+
required: true,
|
|
16114
|
+
description: "System, package, or change under review"
|
|
16115
|
+
}
|
|
16116
|
+
],
|
|
16117
|
+
tasks: [
|
|
16118
|
+
{
|
|
16119
|
+
position: 0,
|
|
16120
|
+
title_pattern: "Map trust boundaries for {target}",
|
|
16121
|
+
priority: "critical",
|
|
16122
|
+
tags: ["security", "threat-model"]
|
|
16123
|
+
},
|
|
16124
|
+
{
|
|
16125
|
+
position: 1,
|
|
16126
|
+
title_pattern: "Scan {target} for vulnerabilities and secret exposure",
|
|
16127
|
+
priority: "critical",
|
|
16128
|
+
tags: ["security", "scan"],
|
|
16129
|
+
depends_on_positions: [0]
|
|
16130
|
+
},
|
|
16131
|
+
{
|
|
16132
|
+
position: 2,
|
|
16133
|
+
title_pattern: "Review authz, data access, and dependency risks in {target}",
|
|
16134
|
+
priority: "critical",
|
|
16135
|
+
tags: ["security", "review"],
|
|
16136
|
+
depends_on_positions: [0]
|
|
16137
|
+
},
|
|
16138
|
+
{
|
|
16139
|
+
position: 3,
|
|
16140
|
+
title_pattern: "Fix critical security findings in {target}",
|
|
16141
|
+
priority: "critical",
|
|
16142
|
+
tags: ["security", "fix"],
|
|
16143
|
+
depends_on_positions: [1, 2]
|
|
16144
|
+
},
|
|
16145
|
+
{
|
|
16146
|
+
position: 4,
|
|
16147
|
+
title_pattern: "Retest {target} security fixes",
|
|
16148
|
+
priority: "high",
|
|
16149
|
+
tags: ["security", "verification"],
|
|
16150
|
+
depends_on_positions: [3]
|
|
16151
|
+
},
|
|
16152
|
+
{
|
|
16153
|
+
position: 5,
|
|
16154
|
+
title_pattern: "Write local security review report for {target}",
|
|
16155
|
+
priority: "medium",
|
|
16156
|
+
tags: ["security", "report"],
|
|
16157
|
+
depends_on_positions: [4]
|
|
16158
|
+
}
|
|
16159
|
+
]
|
|
16160
|
+
},
|
|
16161
|
+
{
|
|
16162
|
+
name: "release",
|
|
16163
|
+
description: "Prepare, verify, publish, install, and smoke test a package release.",
|
|
16164
|
+
category: "release",
|
|
16165
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16166
|
+
variables: [
|
|
16167
|
+
{ name: "package", required: true, description: "Package name" },
|
|
16168
|
+
{
|
|
16169
|
+
name: "version",
|
|
16170
|
+
required: false,
|
|
16171
|
+
default: "patch",
|
|
16172
|
+
description: "Release version or bump type"
|
|
16173
|
+
}
|
|
16174
|
+
],
|
|
16175
|
+
tasks: [
|
|
16176
|
+
{
|
|
16177
|
+
position: 0,
|
|
16178
|
+
title_pattern: "Prepare {package} {version} release notes",
|
|
16179
|
+
priority: "medium",
|
|
16180
|
+
tags: ["release", "docs"]
|
|
16181
|
+
},
|
|
16182
|
+
{
|
|
16183
|
+
position: 1,
|
|
16184
|
+
title_pattern: "Run full tests for {package}",
|
|
16185
|
+
priority: "critical",
|
|
16186
|
+
tags: ["release", "test"]
|
|
16187
|
+
},
|
|
16188
|
+
{
|
|
16189
|
+
position: 2,
|
|
16190
|
+
title_pattern: "Run build and release verification for {package}",
|
|
16191
|
+
priority: "critical",
|
|
16192
|
+
tags: ["release", "verification"],
|
|
16193
|
+
depends_on_positions: [1]
|
|
16194
|
+
},
|
|
16195
|
+
{
|
|
16196
|
+
position: 3,
|
|
16197
|
+
title_pattern: "Scan {package} release diff for secrets",
|
|
16198
|
+
priority: "critical",
|
|
16199
|
+
tags: ["release", "security"],
|
|
16200
|
+
depends_on_positions: [2]
|
|
16201
|
+
},
|
|
16202
|
+
{
|
|
16203
|
+
position: 4,
|
|
16204
|
+
title_pattern: "Publish {package} {version}",
|
|
16205
|
+
priority: "high",
|
|
16206
|
+
tags: ["release", "publish"],
|
|
16207
|
+
depends_on_positions: [3]
|
|
16208
|
+
},
|
|
16209
|
+
{
|
|
16210
|
+
position: 5,
|
|
16211
|
+
title_pattern: "Install and smoke test published {package}",
|
|
16212
|
+
priority: "high",
|
|
16213
|
+
tags: ["release", "smoke"],
|
|
16214
|
+
depends_on_positions: [4]
|
|
16215
|
+
}
|
|
16216
|
+
]
|
|
16217
|
+
},
|
|
16218
|
+
{
|
|
16219
|
+
name: "migration",
|
|
16220
|
+
description: "Plan, test, apply, and verify a schema or data migration.",
|
|
16221
|
+
category: "migration",
|
|
16222
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16223
|
+
variables: [
|
|
16224
|
+
{
|
|
16225
|
+
name: "migration",
|
|
16226
|
+
required: true,
|
|
16227
|
+
description: "Migration name or objective"
|
|
16228
|
+
}
|
|
16229
|
+
],
|
|
16230
|
+
tasks: [
|
|
16231
|
+
{
|
|
16232
|
+
position: 0,
|
|
16233
|
+
title_pattern: "Design migration plan for {migration}",
|
|
16234
|
+
priority: "critical",
|
|
16235
|
+
tags: ["migration", "plan"]
|
|
16236
|
+
},
|
|
16237
|
+
{
|
|
16238
|
+
position: 1,
|
|
16239
|
+
title_pattern: "Write rollback plan for {migration}",
|
|
16240
|
+
priority: "critical",
|
|
16241
|
+
tags: ["migration", "rollback"],
|
|
16242
|
+
depends_on_positions: [0]
|
|
16243
|
+
},
|
|
16244
|
+
{
|
|
16245
|
+
position: 2,
|
|
16246
|
+
title_pattern: "Implement migration {migration}",
|
|
16247
|
+
priority: "critical",
|
|
16248
|
+
tags: ["migration", "implementation"],
|
|
16249
|
+
depends_on_positions: [0]
|
|
16250
|
+
},
|
|
16251
|
+
{
|
|
16252
|
+
position: 3,
|
|
16253
|
+
title_pattern: "Test migration {migration} on fixture data",
|
|
16254
|
+
priority: "high",
|
|
16255
|
+
tags: ["migration", "test"],
|
|
16256
|
+
depends_on_positions: [2]
|
|
16257
|
+
},
|
|
16258
|
+
{
|
|
16259
|
+
position: 4,
|
|
16260
|
+
title_pattern: "Run migration {migration} verification and drift checks",
|
|
16261
|
+
priority: "high",
|
|
16262
|
+
tags: ["migration", "verification"],
|
|
16263
|
+
depends_on_positions: [1, 3]
|
|
16264
|
+
},
|
|
16265
|
+
{
|
|
16266
|
+
position: 5,
|
|
16267
|
+
title_pattern: "Document migration {migration} evidence",
|
|
16268
|
+
priority: "medium",
|
|
16269
|
+
tags: ["migration", "docs"],
|
|
16270
|
+
depends_on_positions: [4]
|
|
16271
|
+
}
|
|
16272
|
+
]
|
|
16273
|
+
},
|
|
16274
|
+
{
|
|
16275
|
+
name: "incident",
|
|
16276
|
+
description: "Triage, mitigate, repair, verify, and retrospect an incident.",
|
|
16277
|
+
category: "incident",
|
|
16278
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16279
|
+
variables: [
|
|
16280
|
+
{ name: "incident", required: true, description: "Incident summary" }
|
|
16281
|
+
],
|
|
16282
|
+
tasks: [
|
|
16283
|
+
{
|
|
16284
|
+
position: 0,
|
|
16285
|
+
title_pattern: "Triage incident: {incident}",
|
|
16286
|
+
priority: "critical",
|
|
16287
|
+
tags: ["incident", "triage"]
|
|
16288
|
+
},
|
|
16289
|
+
{
|
|
16290
|
+
position: 1,
|
|
16291
|
+
title_pattern: "Mitigate customer impact for {incident}",
|
|
16292
|
+
priority: "critical",
|
|
16293
|
+
tags: ["incident", "mitigation"],
|
|
16294
|
+
depends_on_positions: [0]
|
|
16295
|
+
},
|
|
16296
|
+
{
|
|
16297
|
+
position: 2,
|
|
16298
|
+
title_pattern: "Diagnose root cause for {incident}",
|
|
16299
|
+
priority: "critical",
|
|
16300
|
+
tags: ["incident", "diagnosis"],
|
|
16301
|
+
depends_on_positions: [0]
|
|
16302
|
+
},
|
|
16303
|
+
{
|
|
16304
|
+
position: 3,
|
|
16305
|
+
title_pattern: "Implement durable repair for {incident}",
|
|
16306
|
+
priority: "critical",
|
|
16307
|
+
tags: ["incident", "repair"],
|
|
16308
|
+
depends_on_positions: [2]
|
|
16309
|
+
},
|
|
16310
|
+
{
|
|
16311
|
+
position: 4,
|
|
16312
|
+
title_pattern: "Verify recovery from {incident}",
|
|
16313
|
+
priority: "high",
|
|
16314
|
+
tags: ["incident", "verification"],
|
|
16315
|
+
depends_on_positions: [1, 3]
|
|
16316
|
+
},
|
|
16317
|
+
{
|
|
16318
|
+
position: 5,
|
|
16319
|
+
title_pattern: "Write retrospective for {incident}",
|
|
16320
|
+
priority: "medium",
|
|
16321
|
+
tags: ["incident", "retro"],
|
|
16322
|
+
depends_on_positions: [4]
|
|
16323
|
+
}
|
|
16324
|
+
]
|
|
16325
|
+
},
|
|
16326
|
+
{
|
|
16327
|
+
name: "docs-refresh",
|
|
16328
|
+
description: "Audit, update, validate, and publish documentation changes.",
|
|
16329
|
+
category: "docs",
|
|
16330
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16331
|
+
variables: [
|
|
16332
|
+
{
|
|
16333
|
+
name: "area",
|
|
16334
|
+
required: true,
|
|
16335
|
+
description: "Documentation area or product surface"
|
|
16336
|
+
}
|
|
16337
|
+
],
|
|
16338
|
+
tasks: [
|
|
16339
|
+
{
|
|
16340
|
+
position: 0,
|
|
16341
|
+
title_pattern: "Audit current docs for {area}",
|
|
16342
|
+
priority: "medium",
|
|
16343
|
+
tags: ["docs", "audit"]
|
|
16344
|
+
},
|
|
16345
|
+
{
|
|
16346
|
+
position: 1,
|
|
16347
|
+
title_pattern: "Update examples and commands for {area}",
|
|
16348
|
+
priority: "medium",
|
|
16349
|
+
tags: ["docs", "examples"],
|
|
16350
|
+
depends_on_positions: [0]
|
|
16351
|
+
},
|
|
16352
|
+
{
|
|
16353
|
+
position: 2,
|
|
16354
|
+
title_pattern: "Validate docs snippets for {area}",
|
|
16355
|
+
priority: "high",
|
|
16356
|
+
tags: ["docs", "verification"],
|
|
16357
|
+
depends_on_positions: [1]
|
|
16358
|
+
},
|
|
16359
|
+
{
|
|
16360
|
+
position: 3,
|
|
16361
|
+
title_pattern: "Refresh screenshots or generated artifacts for {area}",
|
|
16362
|
+
priority: "medium",
|
|
16363
|
+
tags: ["docs", "assets"],
|
|
16364
|
+
depends_on_positions: [1]
|
|
16365
|
+
},
|
|
16366
|
+
{
|
|
16367
|
+
position: 4,
|
|
16368
|
+
title_pattern: "Publish docs refresh for {area}",
|
|
16369
|
+
priority: "medium",
|
|
16370
|
+
tags: ["docs", "release"],
|
|
16371
|
+
depends_on_positions: [2, 3]
|
|
16372
|
+
}
|
|
16373
|
+
]
|
|
16374
|
+
},
|
|
16375
|
+
{
|
|
16376
|
+
name: "qa",
|
|
16377
|
+
description: "Build a focused QA plan, execute checks, file defects, and sign off.",
|
|
16378
|
+
category: "qa",
|
|
16379
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16380
|
+
variables: [
|
|
16381
|
+
{
|
|
16382
|
+
name: "target",
|
|
16383
|
+
required: true,
|
|
16384
|
+
description: "Feature, release, or workflow under QA"
|
|
16385
|
+
}
|
|
16386
|
+
],
|
|
16387
|
+
tasks: [
|
|
16388
|
+
{
|
|
16389
|
+
position: 0,
|
|
16390
|
+
title_pattern: "Create QA matrix for {target}",
|
|
16391
|
+
priority: "high",
|
|
16392
|
+
tags: ["qa", "plan"]
|
|
16393
|
+
},
|
|
16394
|
+
{
|
|
16395
|
+
position: 1,
|
|
16396
|
+
title_pattern: "Run happy-path QA for {target}",
|
|
16397
|
+
priority: "high",
|
|
16398
|
+
tags: ["qa", "manual"],
|
|
16399
|
+
depends_on_positions: [0]
|
|
16400
|
+
},
|
|
16401
|
+
{
|
|
16402
|
+
position: 2,
|
|
16403
|
+
title_pattern: "Run edge-case QA for {target}",
|
|
16404
|
+
priority: "high",
|
|
16405
|
+
tags: ["qa", "edge-cases"],
|
|
16406
|
+
depends_on_positions: [0]
|
|
16407
|
+
},
|
|
16408
|
+
{
|
|
16409
|
+
position: 3,
|
|
16410
|
+
title_pattern: "File and dedupe QA defects for {target}",
|
|
16411
|
+
priority: "medium",
|
|
16412
|
+
tags: ["qa", "bugs"],
|
|
16413
|
+
depends_on_positions: [1, 2]
|
|
16414
|
+
},
|
|
16415
|
+
{
|
|
16416
|
+
position: 4,
|
|
16417
|
+
title_pattern: "Verify QA fixes for {target}",
|
|
16418
|
+
priority: "high",
|
|
16419
|
+
tags: ["qa", "verification"],
|
|
16420
|
+
depends_on_positions: [3]
|
|
16421
|
+
},
|
|
16422
|
+
{
|
|
16423
|
+
position: 5,
|
|
16424
|
+
title_pattern: "Record QA signoff for {target}",
|
|
16425
|
+
priority: "medium",
|
|
16426
|
+
tags: ["qa", "signoff"],
|
|
16427
|
+
depends_on_positions: [4]
|
|
16428
|
+
}
|
|
16429
|
+
]
|
|
16430
|
+
},
|
|
16431
|
+
{
|
|
16432
|
+
name: "open-source-project",
|
|
16433
|
+
description: "Full open-source project bootstrap \u2014 scaffold to publish",
|
|
16434
|
+
category: "open-source",
|
|
16435
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16436
|
+
variables: [
|
|
16437
|
+
{ name: "name", required: true, description: "Service name" },
|
|
16438
|
+
{
|
|
16439
|
+
name: "org",
|
|
16440
|
+
required: false,
|
|
16441
|
+
default: "hasna",
|
|
16442
|
+
description: "GitHub org"
|
|
16443
|
+
}
|
|
16444
|
+
],
|
|
16445
|
+
tasks: [
|
|
16446
|
+
{
|
|
16447
|
+
position: 0,
|
|
16448
|
+
title_pattern: "Scaffold {name} package structure",
|
|
16449
|
+
priority: "critical"
|
|
16450
|
+
},
|
|
16451
|
+
{
|
|
16452
|
+
position: 1,
|
|
16453
|
+
title_pattern: "Create {name} SQLite database + migrations",
|
|
16454
|
+
priority: "critical",
|
|
16455
|
+
depends_on_positions: [0]
|
|
16456
|
+
},
|
|
16457
|
+
{
|
|
16458
|
+
position: 2,
|
|
16459
|
+
title_pattern: "Implement {name} CRUD operations",
|
|
16460
|
+
priority: "high",
|
|
16461
|
+
depends_on_positions: [1]
|
|
16462
|
+
},
|
|
16463
|
+
{
|
|
16464
|
+
position: 3,
|
|
16465
|
+
title_pattern: "Build {name} MCP server with standard tools",
|
|
16466
|
+
priority: "high",
|
|
16467
|
+
depends_on_positions: [2]
|
|
16468
|
+
},
|
|
16469
|
+
{
|
|
16470
|
+
position: 4,
|
|
16471
|
+
title_pattern: "Build {name} CLI with Commander.js",
|
|
16472
|
+
priority: "high",
|
|
16473
|
+
depends_on_positions: [2]
|
|
16474
|
+
},
|
|
16475
|
+
{
|
|
16476
|
+
position: 5,
|
|
16477
|
+
title_pattern: "Build {name} REST API",
|
|
16478
|
+
priority: "medium",
|
|
16479
|
+
depends_on_positions: [2]
|
|
16480
|
+
},
|
|
16481
|
+
{
|
|
16482
|
+
position: 6,
|
|
16483
|
+
title_pattern: "Write unit tests for {name}",
|
|
16484
|
+
priority: "high",
|
|
16485
|
+
depends_on_positions: [2, 3, 4]
|
|
16486
|
+
},
|
|
16487
|
+
{
|
|
16488
|
+
position: 7,
|
|
16489
|
+
title_pattern: "Add Apache 2.0 license and README",
|
|
16490
|
+
priority: "medium",
|
|
16491
|
+
depends_on_positions: [0]
|
|
16492
|
+
},
|
|
16493
|
+
{
|
|
16494
|
+
position: 8,
|
|
16495
|
+
title_pattern: "Create GitHub repo {org}/{name}",
|
|
16496
|
+
priority: "medium",
|
|
16497
|
+
depends_on_positions: [7]
|
|
16498
|
+
},
|
|
16499
|
+
{
|
|
16500
|
+
position: 9,
|
|
16501
|
+
title_pattern: "Add local backup and restore workflow for {name}",
|
|
16502
|
+
priority: "medium",
|
|
16503
|
+
depends_on_positions: [1]
|
|
16504
|
+
},
|
|
16505
|
+
{
|
|
16506
|
+
position: 10,
|
|
16507
|
+
title_pattern: "Add agent tools (register_agent, heartbeat, set_focus, list_agents)",
|
|
16508
|
+
priority: "medium",
|
|
16509
|
+
depends_on_positions: [3]
|
|
16510
|
+
},
|
|
16511
|
+
{
|
|
16512
|
+
position: 11,
|
|
16513
|
+
title_pattern: "Publish @hasna/{name} to npm",
|
|
16514
|
+
priority: "high",
|
|
16515
|
+
depends_on_positions: [6, 7, 8]
|
|
16516
|
+
},
|
|
16517
|
+
{
|
|
16518
|
+
position: 12,
|
|
16519
|
+
title_pattern: "Install @hasna/{name} globally and verify",
|
|
16520
|
+
priority: "medium",
|
|
16521
|
+
depends_on_positions: [11]
|
|
16522
|
+
}
|
|
16523
|
+
]
|
|
16524
|
+
},
|
|
16525
|
+
{
|
|
16526
|
+
name: "release",
|
|
16527
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16528
|
+
category: "ops",
|
|
16529
|
+
description: "Version release workflow \u2014 test, changelog, publish, verify",
|
|
16530
|
+
variables: [
|
|
16531
|
+
{
|
|
16532
|
+
name: "version",
|
|
16533
|
+
required: true,
|
|
16534
|
+
description: "Release version (e.g. 1.2.0)"
|
|
16535
|
+
},
|
|
16536
|
+
{
|
|
16537
|
+
name: "package",
|
|
16538
|
+
required: false,
|
|
16539
|
+
default: "package",
|
|
16540
|
+
description: "Package name"
|
|
16541
|
+
}
|
|
16542
|
+
],
|
|
16543
|
+
tasks: [
|
|
16544
|
+
{
|
|
16545
|
+
position: 0,
|
|
16546
|
+
title_pattern: "Run full test suite for {package} {version}",
|
|
16547
|
+
priority: "critical"
|
|
16548
|
+
},
|
|
16549
|
+
{
|
|
16550
|
+
position: 1,
|
|
16551
|
+
title_pattern: "Update changelog for {version}",
|
|
16552
|
+
priority: "high",
|
|
16553
|
+
depends_on_positions: [0]
|
|
16554
|
+
},
|
|
16555
|
+
{
|
|
16556
|
+
position: 2,
|
|
16557
|
+
title_pattern: "Bump version to {version}",
|
|
16558
|
+
priority: "high",
|
|
16559
|
+
depends_on_positions: [1]
|
|
16560
|
+
},
|
|
16561
|
+
{
|
|
16562
|
+
position: 3,
|
|
16563
|
+
title_pattern: "Publish {package} {version}",
|
|
16564
|
+
priority: "critical",
|
|
16565
|
+
depends_on_positions: [2]
|
|
16566
|
+
},
|
|
16567
|
+
{
|
|
16568
|
+
position: 4,
|
|
16569
|
+
title_pattern: "Verify {package} {version} install and CLI",
|
|
16570
|
+
priority: "high",
|
|
16571
|
+
depends_on_positions: [3]
|
|
16572
|
+
}
|
|
16573
|
+
]
|
|
16574
|
+
},
|
|
16575
|
+
{
|
|
16576
|
+
name: "docs-refresh",
|
|
16577
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16578
|
+
category: "workflow",
|
|
16579
|
+
description: "Documentation refresh \u2014 audit, update, verify links",
|
|
16580
|
+
variables: [
|
|
16581
|
+
{
|
|
16582
|
+
name: "scope",
|
|
16583
|
+
required: true,
|
|
16584
|
+
description: "Docs scope (README, API, AGENTS.md)"
|
|
16585
|
+
}
|
|
16586
|
+
],
|
|
16587
|
+
tasks: [
|
|
16588
|
+
{
|
|
16589
|
+
position: 0,
|
|
16590
|
+
title_pattern: "Audit outdated sections in {scope}",
|
|
16591
|
+
priority: "high"
|
|
16592
|
+
},
|
|
16593
|
+
{
|
|
16594
|
+
position: 1,
|
|
16595
|
+
title_pattern: "Update {scope} for current behavior",
|
|
16596
|
+
priority: "high",
|
|
16597
|
+
depends_on_positions: [0]
|
|
16598
|
+
},
|
|
16599
|
+
{
|
|
16600
|
+
position: 2,
|
|
16601
|
+
title_pattern: "Verify examples in {scope} still work",
|
|
16602
|
+
priority: "medium",
|
|
16603
|
+
depends_on_positions: [1]
|
|
16604
|
+
},
|
|
16605
|
+
{
|
|
16606
|
+
position: 3,
|
|
16607
|
+
title_pattern: "Fix broken links in {scope}",
|
|
16608
|
+
priority: "medium",
|
|
16609
|
+
depends_on_positions: [1]
|
|
16610
|
+
}
|
|
16611
|
+
]
|
|
16612
|
+
},
|
|
16613
|
+
{
|
|
16614
|
+
name: "migration",
|
|
16615
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16616
|
+
category: "ops",
|
|
16617
|
+
description: "Schema/data migration workflow",
|
|
16618
|
+
variables: [
|
|
16619
|
+
{
|
|
16620
|
+
name: "migration",
|
|
16621
|
+
required: true,
|
|
16622
|
+
description: "Migration name or ID"
|
|
16623
|
+
},
|
|
16624
|
+
{
|
|
16625
|
+
name: "target",
|
|
16626
|
+
required: false,
|
|
16627
|
+
default: "sqlite",
|
|
16628
|
+
description: "Target store"
|
|
16629
|
+
}
|
|
16630
|
+
],
|
|
16631
|
+
tasks: [
|
|
16632
|
+
{
|
|
16633
|
+
position: 0,
|
|
16634
|
+
title_pattern: "Write forward migration for {migration} ({target})",
|
|
16635
|
+
priority: "critical"
|
|
16636
|
+
},
|
|
16637
|
+
{
|
|
16638
|
+
position: 1,
|
|
16639
|
+
title_pattern: "Add migration tests for {migration}",
|
|
16640
|
+
priority: "high",
|
|
16641
|
+
depends_on_positions: [0]
|
|
16642
|
+
},
|
|
16643
|
+
{
|
|
16644
|
+
position: 2,
|
|
16645
|
+
title_pattern: "Run migration dry-run on copy",
|
|
16646
|
+
priority: "high",
|
|
16647
|
+
depends_on_positions: [1]
|
|
16648
|
+
},
|
|
16649
|
+
{
|
|
16650
|
+
position: 3,
|
|
16651
|
+
title_pattern: "Apply {migration} and verify integrity",
|
|
16652
|
+
priority: "critical",
|
|
16653
|
+
depends_on_positions: [2]
|
|
16654
|
+
},
|
|
16655
|
+
{
|
|
16656
|
+
position: 4,
|
|
16657
|
+
title_pattern: "Document rollback for {migration}",
|
|
16658
|
+
priority: "medium",
|
|
16659
|
+
depends_on_positions: [3]
|
|
16660
|
+
}
|
|
16661
|
+
]
|
|
16662
|
+
},
|
|
16663
|
+
{
|
|
16664
|
+
name: "incident-response",
|
|
16665
|
+
version: BUILTIN_TEMPLATE_LIBRARY_VERSION,
|
|
16666
|
+
category: "ops",
|
|
16667
|
+
description: "Incident triage, mitigation, postmortem",
|
|
16668
|
+
variables: [
|
|
16669
|
+
{ name: "incident", required: true, description: "Incident summary" }
|
|
16670
|
+
],
|
|
16671
|
+
tasks: [
|
|
16672
|
+
{
|
|
16673
|
+
position: 0,
|
|
16674
|
+
title_pattern: "Triage: {incident}",
|
|
16675
|
+
priority: "critical",
|
|
16676
|
+
tags: ["incident"]
|
|
16677
|
+
},
|
|
16678
|
+
{
|
|
16679
|
+
position: 1,
|
|
16680
|
+
title_pattern: "Mitigate impact of {incident}",
|
|
16681
|
+
priority: "critical",
|
|
16682
|
+
depends_on_positions: [0],
|
|
16683
|
+
tags: ["incident"]
|
|
16684
|
+
},
|
|
16685
|
+
{
|
|
16686
|
+
position: 2,
|
|
16687
|
+
title_pattern: "Identify root cause: {incident}",
|
|
16688
|
+
priority: "critical",
|
|
16689
|
+
depends_on_positions: [1]
|
|
16690
|
+
},
|
|
16691
|
+
{
|
|
16692
|
+
position: 3,
|
|
16693
|
+
title_pattern: "Permanent fix for {incident}",
|
|
16694
|
+
priority: "high",
|
|
16695
|
+
depends_on_positions: [2]
|
|
16696
|
+
},
|
|
16697
|
+
{
|
|
16698
|
+
position: 4,
|
|
16699
|
+
title_pattern: "Write postmortem for {incident}",
|
|
16700
|
+
priority: "medium",
|
|
16701
|
+
depends_on_positions: [3]
|
|
16702
|
+
},
|
|
16703
|
+
{
|
|
16704
|
+
position: 5,
|
|
16705
|
+
title_pattern: "Add monitoring/alert for {incident} class",
|
|
16706
|
+
priority: "medium",
|
|
16707
|
+
depends_on_positions: [4]
|
|
16708
|
+
}
|
|
16709
|
+
]
|
|
16710
|
+
}
|
|
16711
|
+
];
|
|
16712
|
+
function templateMetadata(template) {
|
|
16713
|
+
return {
|
|
16714
|
+
source: BUILTIN_TEMPLATE_LIBRARY_SOURCE,
|
|
16715
|
+
library_version: template.version,
|
|
16716
|
+
category: template.category,
|
|
16717
|
+
template_file: `${template.name}.json`,
|
|
16718
|
+
local_only: true,
|
|
16719
|
+
marketplace_free: true
|
|
16720
|
+
};
|
|
16721
|
+
}
|
|
16722
|
+
function listBuiltinTemplates() {
|
|
16723
|
+
return BUILTIN_TEMPLATES.map((template) => ({
|
|
16724
|
+
...template,
|
|
16725
|
+
variables: template.variables.map((variable) => ({ ...variable })),
|
|
16726
|
+
tasks: template.tasks.map((task) => ({
|
|
16727
|
+
...task,
|
|
16728
|
+
tags: task.tags ? [...task.tags] : undefined
|
|
16729
|
+
}))
|
|
16730
|
+
}));
|
|
16731
|
+
}
|
|
16732
|
+
function getBuiltinTemplate(name) {
|
|
16733
|
+
return listBuiltinTemplates().find((template) => template.name === name) ?? null;
|
|
16734
|
+
}
|
|
16735
|
+
function exportBuiltinTemplate(name) {
|
|
16736
|
+
const template = getBuiltinTemplate(name);
|
|
16737
|
+
if (!template)
|
|
16738
|
+
throw new Error(`Built-in template not found: ${name}`);
|
|
16739
|
+
return {
|
|
16740
|
+
name: template.name,
|
|
16741
|
+
title_pattern: `${template.name}: {${template.variables[0]?.name ?? "name"}}`,
|
|
16742
|
+
description: template.description,
|
|
16743
|
+
priority: "medium",
|
|
16744
|
+
tags: [template.category, "local-template"],
|
|
16745
|
+
variables: template.variables,
|
|
16746
|
+
project_id: null,
|
|
16747
|
+
plan_id: null,
|
|
16748
|
+
metadata: templateMetadata(template),
|
|
16749
|
+
tasks: template.tasks.map((task) => ({
|
|
16750
|
+
position: task.position,
|
|
16751
|
+
title_pattern: task.title_pattern,
|
|
16752
|
+
description: task.description ?? null,
|
|
16753
|
+
priority: task.priority ?? "medium",
|
|
16754
|
+
tags: task.tags ?? [template.category],
|
|
16755
|
+
task_type: task.task_type ?? null,
|
|
16756
|
+
condition: task.condition ?? null,
|
|
16757
|
+
include_template_id: task.include_template_id ?? null,
|
|
16758
|
+
depends_on_positions: task.depends_on_positions ?? task.depends_on ?? [],
|
|
16759
|
+
metadata: task.metadata ?? { category: template.category }
|
|
16760
|
+
}))
|
|
16761
|
+
};
|
|
16762
|
+
}
|
|
16763
|
+
function exportBuiltinTemplateFiles() {
|
|
16764
|
+
return BUILTIN_TEMPLATES.map((template) => ({
|
|
16765
|
+
filename: `${template.name}.json`,
|
|
16766
|
+
template: exportBuiltinTemplate(template.name)
|
|
16767
|
+
}));
|
|
16768
|
+
}
|
|
16769
|
+
function writeBuiltinTemplateFiles(directory) {
|
|
16770
|
+
mkdirSync5(directory, { recursive: true });
|
|
16771
|
+
const files = [];
|
|
16772
|
+
for (const entry of exportBuiltinTemplateFiles()) {
|
|
16773
|
+
const path = join7(directory, entry.filename);
|
|
16774
|
+
writeFileSync3(path, `${JSON.stringify(entry.template, null, 2)}
|
|
16775
|
+
`, "utf-8");
|
|
16776
|
+
files.push(path);
|
|
16777
|
+
}
|
|
16778
|
+
return { directory, written: files.length, files };
|
|
16779
|
+
}
|
|
16780
|
+
function builtinTemplateInputs() {
|
|
16781
|
+
return listBuiltinTemplates().map((bt) => ({
|
|
16782
|
+
name: bt.name,
|
|
16783
|
+
title_pattern: `${bt.name}: {${bt.variables[0]?.name || "name"}}`,
|
|
16784
|
+
description: bt.description,
|
|
16785
|
+
tags: [bt.category, "local-template"],
|
|
16786
|
+
variables: bt.variables,
|
|
16787
|
+
metadata: templateMetadata(bt),
|
|
16788
|
+
tasks: bt.tasks.map((task) => ({
|
|
16789
|
+
title_pattern: task.title_pattern,
|
|
16790
|
+
description: task.description,
|
|
16791
|
+
priority: task.priority,
|
|
16792
|
+
tags: task.tags,
|
|
16793
|
+
task_type: task.task_type,
|
|
16794
|
+
depends_on: task.depends_on_positions || task.depends_on,
|
|
16795
|
+
metadata: task.metadata
|
|
16796
|
+
}))
|
|
16797
|
+
}));
|
|
16798
|
+
}
|
|
16799
|
+
|
|
16800
|
+
// src/lib/plan-schedule.ts
|
|
16801
|
+
function validatePlanSchedule(value) {
|
|
16802
|
+
for (const key of ["start_date", "end_date"]) {
|
|
16803
|
+
const date = value[key];
|
|
16804
|
+
if (date === undefined || date === null)
|
|
16805
|
+
continue;
|
|
16806
|
+
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)
|
|
16807
|
+
return `${key} must be a valid ISO calendar date (YYYY-MM-DD)`;
|
|
16808
|
+
}
|
|
16809
|
+
if (typeof value.start_date === "string" && typeof value.end_date === "string" && value.start_date > value.end_date)
|
|
16810
|
+
return "end_date must not precede start_date";
|
|
16811
|
+
return null;
|
|
16812
|
+
}
|
|
16813
|
+
|
|
16814
|
+
// src/storage/atomic-project-migration.ts
|
|
16815
|
+
init_types();
|
|
16816
|
+
import { createHash as createHash5 } from "crypto";
|
|
16817
|
+
var families = { projects: "projects", tasks: "tasks", plans: "plans", taskLists: "task_lists", auditHistory: "audit_history" };
|
|
16818
|
+
|
|
16819
|
+
class AtomicMigrationError extends Error {
|
|
16820
|
+
status;
|
|
16821
|
+
record;
|
|
16822
|
+
constructor(status, message, record) {
|
|
16823
|
+
super(message);
|
|
16824
|
+
this.status = status;
|
|
16825
|
+
this.record = record;
|
|
16826
|
+
this.name = "AtomicMigrationError";
|
|
16827
|
+
}
|
|
16828
|
+
}
|
|
16829
|
+
function fail(message, status = 409, record) {
|
|
16830
|
+
throw new AtomicMigrationError(status, message, record && { object_type: record.object_type, object_id: record.object_id });
|
|
16831
|
+
}
|
|
16832
|
+
function plain(value) {
|
|
16833
|
+
return !!value && typeof value === "object" && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
16834
|
+
}
|
|
16835
|
+
function canonical(value, depth = 0) {
|
|
16836
|
+
if (depth > 32)
|
|
16837
|
+
fail("Migration JSON depth exceeds 32", 400);
|
|
16838
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
16839
|
+
return JSON.stringify(value);
|
|
16840
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
16841
|
+
return JSON.stringify(value);
|
|
16842
|
+
if (Array.isArray(value))
|
|
16843
|
+
return "[" + value.map((v) => canonical(v, depth + 1)).join(",") + "]";
|
|
16844
|
+
if (plain(value))
|
|
16845
|
+
return "{" + Object.keys(value).sort().map((k) => JSON.stringify(k) + ":" + canonical(value[k], depth + 1)).join(",") + "}";
|
|
16846
|
+
return fail("Migration requires finite JSON values", 400);
|
|
16847
|
+
}
|
|
16848
|
+
function atomicMigrationHash(value) {
|
|
16849
|
+
return createHash5("sha256").update(canonical(value)).digest("hex");
|
|
16850
|
+
}
|
|
16851
|
+
function stamp(value) {
|
|
16852
|
+
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)
|
|
16853
|
+
fail("Migration clocks require canonical millisecond UTC timestamps", 400);
|
|
16854
|
+
return value;
|
|
16855
|
+
}
|
|
16856
|
+
function version(value) {
|
|
16857
|
+
if (value == null)
|
|
16858
|
+
return 0;
|
|
16859
|
+
if (!Number.isSafeInteger(value) || Number(value) < 0)
|
|
16860
|
+
fail("Invalid migration version", 400);
|
|
16861
|
+
return value;
|
|
16862
|
+
}
|
|
16863
|
+
function key(row) {
|
|
16864
|
+
return row.object_type + "\x00" + row.object_id;
|
|
16865
|
+
}
|
|
16866
|
+
function normalized(row) {
|
|
16867
|
+
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 };
|
|
16868
|
+
}
|
|
16869
|
+
function digest(row) {
|
|
16870
|
+
return atomicMigrationHash(normalized(row));
|
|
16871
|
+
}
|
|
16872
|
+
function compare(a, b) {
|
|
16873
|
+
return Date.parse(String(a.updated_at)) - Date.parse(String(b.updated_at)) || version(a.version) - version(b.version);
|
|
16874
|
+
}
|
|
16875
|
+
function identity(value) {
|
|
16876
|
+
if (typeof value !== "string" || !value.trim() || value.length > 256 || value.includes("\x00"))
|
|
16877
|
+
fail("Invalid migration record identity", 400);
|
|
16878
|
+
return value;
|
|
16879
|
+
}
|
|
16880
|
+
function validate(request) {
|
|
16881
|
+
if (!plain(request) || request.schema_version !== 1 || typeof request.operation_id !== "string" || !/^[A-Za-z0-9._:-]{8,128}$/.test(request.operation_id))
|
|
16882
|
+
fail("Invalid migration operation identity/version", 400);
|
|
16883
|
+
if (Object.keys(request).some((k) => !["schema_version", "operation_id", "expected_authority", "snapshot", "snapshot_hash"].includes(k)) || Buffer.byteLength(canonical(request)) > 2200000)
|
|
16884
|
+
fail("Unsupported or oversized migration envelope", 400);
|
|
16885
|
+
if (!plain(request.expected_authority))
|
|
16886
|
+
fail("Expected migration authority is required", 400);
|
|
16887
|
+
identity(request.expected_authority.tenant_id);
|
|
16888
|
+
identity(request.expected_authority.kid);
|
|
16889
|
+
identity(request.expected_authority.deployment_id);
|
|
16890
|
+
if (!plain(request.snapshot))
|
|
16891
|
+
fail("Migration snapshot must be an object", 400);
|
|
16892
|
+
const encoded = canonical(request.snapshot);
|
|
16893
|
+
if (Buffer.byteLength(encoded) > 2 * 1024 * 1024)
|
|
16894
|
+
fail("Migration snapshot exceeds 2 MiB", 413);
|
|
16895
|
+
if (atomicMigrationHash(request.snapshot) !== request.snapshot_hash)
|
|
16896
|
+
fail("Migration snapshot hash mismatch", 400);
|
|
16897
|
+
const allowed = new Set([...Object.keys(families), "tombstones", "exportedAt", "source"]);
|
|
16898
|
+
for (const [name, value] of Object.entries(request.snapshot))
|
|
16899
|
+
if (!allowed.has(name) && !(Array.isArray(value) && value.length === 0))
|
|
16900
|
+
fail(`Unsupported atomic migration family: ${name}`, 400);
|
|
16901
|
+
const rows = [];
|
|
16902
|
+
for (const [name, type] of Object.entries(families)) {
|
|
16903
|
+
const values = request.snapshot[name] ?? [];
|
|
16904
|
+
if (!Array.isArray(values))
|
|
16905
|
+
fail(`Invalid migration array: ${name}`, 400);
|
|
16906
|
+
for (const value of values) {
|
|
16907
|
+
if (!plain(value))
|
|
16908
|
+
fail("Invalid migration record", 400);
|
|
16909
|
+
const id = identity(value.id);
|
|
16910
|
+
const clock = stamp(value.updated_at ?? value.created_at);
|
|
16911
|
+
if (type === "tasks") {
|
|
16912
|
+
if (typeof value.title !== "string" || !TASK_STATUSES.includes(value.status) || !TASK_PRIORITIES.includes(value.priority))
|
|
16913
|
+
fail("Invalid task migration payload", 400);
|
|
16914
|
+
if (value.locked_by || value.runner_id)
|
|
16915
|
+
fail("Active task leases require separate migration reconciliation", 409);
|
|
16916
|
+
}
|
|
16917
|
+
if (type === "plans" && (typeof value.name !== "string" || !PLAN_STATUSES.includes(value.status)))
|
|
16918
|
+
fail("Invalid plan migration payload", 400);
|
|
16919
|
+
if (type === "projects" && typeof value.name !== "string")
|
|
16920
|
+
fail("Invalid project migration payload", 400);
|
|
16921
|
+
if (type === "audit_history" && (typeof value.task_id !== "string" || typeof value.action !== "string"))
|
|
16922
|
+
fail("Invalid history migration payload", 400);
|
|
16923
|
+
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 });
|
|
16924
|
+
}
|
|
16925
|
+
}
|
|
16926
|
+
const tombstones = request.snapshot.tombstones ?? [];
|
|
16927
|
+
if (!Array.isArray(tombstones))
|
|
16928
|
+
fail("Invalid tombstone array", 400);
|
|
16929
|
+
for (const value of tombstones) {
|
|
16930
|
+
if (!plain(value) || value.object_type !== "projects")
|
|
16931
|
+
fail("Atomic migration supports only project tombstones", 400);
|
|
16932
|
+
if (!plain(value.payload))
|
|
16933
|
+
fail("Project tombstone must preserve its original payload", 400);
|
|
16934
|
+
if (stamp(value.updated_at) < stamp(value.deleted_at))
|
|
16935
|
+
fail("Tombstone update clock precedes deletion", 400);
|
|
16936
|
+
const id = identity(value.object_id);
|
|
16937
|
+
if (value.payload.id !== id)
|
|
16938
|
+
fail("Tombstone payload identity mismatch", 400);
|
|
16939
|
+
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 });
|
|
16940
|
+
}
|
|
16941
|
+
if (!rows.length || rows.length > 1000)
|
|
16942
|
+
fail("Atomic migration requires 1-1000 records", 400);
|
|
16943
|
+
const unique4 = new Map;
|
|
16944
|
+
for (const row of rows) {
|
|
16945
|
+
const previous = unique4.get(key(row));
|
|
16946
|
+
if (previous && digest(previous) !== digest(row))
|
|
16947
|
+
fail("Divergent duplicate migration identity", 409, row);
|
|
16948
|
+
unique4.set(key(row), row);
|
|
16949
|
+
}
|
|
16950
|
+
return [...unique4.values()];
|
|
16951
|
+
}
|
|
16952
|
+
function validateGraph(rows) {
|
|
16953
|
+
const live = [...rows.values()].filter((r) => !r.deleted_at);
|
|
16954
|
+
const get = (type, id) => typeof id === "string" ? rows.get(type + "\x00" + id) : undefined;
|
|
16955
|
+
for (const row of live) {
|
|
16956
|
+
if (row.object_type === "audit_history") {
|
|
16957
|
+
if (!get("tasks", row.payload.task_id))
|
|
16958
|
+
fail("Migration history references an unknown task", 409, row);
|
|
16959
|
+
continue;
|
|
16960
|
+
}
|
|
16961
|
+
if (!["projects", "tasks", "plans", "task_lists"].includes(row.object_type))
|
|
16962
|
+
continue;
|
|
16963
|
+
if (row.object_type === "tasks" && row.payload.plan_id) {
|
|
16964
|
+
const plan = get("plans", row.payload.plan_id);
|
|
16965
|
+
if (plan && !plan.deleted_at && (plan.payload.project_id ?? null) !== (row.payload.project_id ?? null))
|
|
16966
|
+
fail("Migration task project conflicts with its plan", 409, row);
|
|
16967
|
+
}
|
|
16968
|
+
if (["tasks", "plans"].includes(row.object_type) && row.payload.task_list_id != null) {
|
|
16969
|
+
const selector = row.payload.task_list_id;
|
|
16970
|
+
if (typeof selector !== "string" || !selector)
|
|
16971
|
+
fail("Invalid migration task-list selector", 409, row);
|
|
16972
|
+
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));
|
|
16973
|
+
const project = get("projects", row.payload.project_id);
|
|
16974
|
+
const canonicalDefault = project && !project.deleted_at && project.payload.task_list_id === selector;
|
|
16975
|
+
if (candidates.length > 1 || !candidates.length && !canonicalDefault)
|
|
16976
|
+
fail("Migration task-list reference is missing or ambiguous; explicit reconciliation required", 409, row);
|
|
16977
|
+
}
|
|
16978
|
+
const refs = [];
|
|
16979
|
+
if (row.object_type === "projects")
|
|
16980
|
+
refs.push(["projects", row.payload.parent_id]);
|
|
16981
|
+
else
|
|
16982
|
+
refs.push(["projects", row.payload.project_id]);
|
|
16983
|
+
if (row.object_type === "tasks")
|
|
16984
|
+
refs.push(["tasks", row.payload.parent_id], ["plans", row.payload.plan_id]);
|
|
16985
|
+
for (const [type, id] of refs) {
|
|
16986
|
+
if (id == null)
|
|
16987
|
+
continue;
|
|
16988
|
+
if (typeof id !== "string" || !id || !get(type, id) || get(type, id).deleted_at)
|
|
16989
|
+
fail("Migration reference is missing or deleted", 409, row);
|
|
16990
|
+
}
|
|
16991
|
+
if (row.object_type === "tasks" && row.payload.parent_id) {
|
|
16992
|
+
const parent = get("tasks", row.payload.parent_id);
|
|
16993
|
+
if ((parent.payload.project_id ?? null) !== (row.payload.project_id ?? null))
|
|
16994
|
+
fail("Migration task parent crosses project scope", 409, row);
|
|
16995
|
+
}
|
|
16996
|
+
}
|
|
16997
|
+
for (const type of ["projects", "tasks"])
|
|
16998
|
+
for (const row of live.filter((r) => r.object_type === type)) {
|
|
16999
|
+
const seen = new Set;
|
|
17000
|
+
let cursor = row;
|
|
17001
|
+
while (cursor) {
|
|
17002
|
+
if (seen.has(cursor.object_id))
|
|
17003
|
+
fail("Migration hierarchy contains a cycle", 409, row);
|
|
17004
|
+
seen.add(cursor.object_id);
|
|
17005
|
+
cursor = get(type, cursor.payload.parent_id);
|
|
17006
|
+
}
|
|
17007
|
+
}
|
|
17008
|
+
const projects = live.filter((r) => r.object_type === "projects").map((r) => r.payload);
|
|
17009
|
+
const lists = live.filter((r) => r.object_type === "task_lists").map((r) => r.payload);
|
|
17010
|
+
const errors = validateSnapshotRoutingRecords(projects, lists);
|
|
17011
|
+
if (errors.length)
|
|
17012
|
+
fail("Migration project/list routing conflict");
|
|
17013
|
+
}
|
|
17014
|
+
function createAtomicProjectMigration(options) {
|
|
17015
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(options.table))
|
|
17016
|
+
throw new Error("Unsafe migration table");
|
|
17017
|
+
return { async apply(request) {
|
|
17018
|
+
const incoming = validate(request);
|
|
17019
|
+
if (!options.client.transaction)
|
|
17020
|
+
fail("Atomic migration requires a transaction-capable backend", 501);
|
|
17021
|
+
await options.ensureSchema();
|
|
17022
|
+
return options.client.transaction(async (client) => {
|
|
17023
|
+
const deadline = Date.now() + 20000;
|
|
17024
|
+
await client.query("SET LOCAL lock_timeout = '5s'");
|
|
17025
|
+
await client.query("SET LOCAL statement_timeout = '15s'");
|
|
17026
|
+
await client.query("SET LOCAL synchronous_commit = 'on'");
|
|
17027
|
+
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");
|
|
17028
|
+
const durable = durability.rows[0];
|
|
17029
|
+
if (durable?.fsync !== "on" || durable.full_page_writes !== "on" || durable.synchronous_commit !== "on")
|
|
17030
|
+
fail("Migration database durability settings are not ready", 503);
|
|
17031
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity',0))", [options.service]);
|
|
17032
|
+
await client.query(`LOCK TABLE ${options.table} IN SHARE ROW EXCLUSIVE MODE`);
|
|
17033
|
+
const journalId = atomicMigrationHash({ operation_id: request.operation_id });
|
|
17034
|
+
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]);
|
|
17035
|
+
const requestHash = atomicMigrationHash(request);
|
|
17036
|
+
if (journal.rows[0]) {
|
|
17037
|
+
if (journal.rows[0].request_hash !== requestHash)
|
|
17038
|
+
fail("Migration operation identity was already used for another request");
|
|
17039
|
+
return journal.rows[0].receipt;
|
|
17040
|
+
}
|
|
17041
|
+
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]);
|
|
17042
|
+
const size = bounds.rows[0];
|
|
17043
|
+
if (!size || Number(size.records) > 1e4 || Number(size.bytes) > 16 * 1024 * 1024 || Number(size.largest) > 2 * 1024 * 1024)
|
|
17044
|
+
fail("Atomic migration destination exceeds its bounded row/byte scope", 413);
|
|
17045
|
+
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]);
|
|
17046
|
+
if (selected.rows.length > 1e4)
|
|
17047
|
+
fail("Atomic migration destination exceeds bounded 10000-record scope", 413);
|
|
17048
|
+
for (const row of selected.rows) {
|
|
17049
|
+
canonical(row.payload);
|
|
17050
|
+
if (Date.now() > deadline)
|
|
17051
|
+
fail("Migration destination validation exceeded time budget", 503);
|
|
17052
|
+
}
|
|
17053
|
+
const original = new Map(selected.rows.map((r) => [key(r), normalized(r)]));
|
|
17054
|
+
const current = new Map(original);
|
|
17055
|
+
const receipts = [];
|
|
17056
|
+
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) });
|
|
17057
|
+
for (const row of incoming.filter((r) => !r.deleted_at)) {
|
|
17058
|
+
const before = current.get(key(row));
|
|
17059
|
+
if (before) {
|
|
17060
|
+
if (digest(before) === digest(row)) {
|
|
17061
|
+
receipt(row, before, before, "identical");
|
|
17062
|
+
continue;
|
|
17063
|
+
}
|
|
17064
|
+
if (before.object_type === "tasks" && (before.payload.locked_by || before.payload.runner_id))
|
|
17065
|
+
fail("Active destination task lease requires reconciliation", 409, before);
|
|
17066
|
+
if (row.object_type === "audit_history")
|
|
17067
|
+
fail("Immutable history identity conflict", 409, row);
|
|
17068
|
+
const clock = compare(row, before);
|
|
17069
|
+
if (clock === 0)
|
|
17070
|
+
fail("Ambiguous equal-clock migration conflict", 409, row);
|
|
17071
|
+
if (clock < 0) {
|
|
17072
|
+
receipt(row, before, before, "superseded");
|
|
17073
|
+
continue;
|
|
17074
|
+
}
|
|
17075
|
+
if (before.deleted_at)
|
|
17076
|
+
fail("Migration cannot implicitly resurrect a deleted record", 409, row);
|
|
17077
|
+
}
|
|
17078
|
+
current.set(key(row), row);
|
|
17079
|
+
receipt(row, before, row, before ? "updated" : "inserted");
|
|
17080
|
+
}
|
|
17081
|
+
for (const row of incoming.filter((r) => r.deleted_at)) {
|
|
17082
|
+
const before = current.get(key(row));
|
|
17083
|
+
if (before) {
|
|
17084
|
+
if (digest(before) === digest(row)) {
|
|
17085
|
+
receipt(row, before, before, "identical");
|
|
17086
|
+
continue;
|
|
17087
|
+
}
|
|
17088
|
+
const clock = compare(row, before);
|
|
17089
|
+
if (clock === 0)
|
|
17090
|
+
fail("Ambiguous equal-clock lifecycle conflict", 409, row);
|
|
17091
|
+
if (clock < 0) {
|
|
17092
|
+
receipt(row, before, before, "superseded");
|
|
17093
|
+
continue;
|
|
17094
|
+
}
|
|
17095
|
+
}
|
|
17096
|
+
for (const [id, linked] of current) {
|
|
17097
|
+
if (linked.deleted_at)
|
|
17098
|
+
continue;
|
|
17099
|
+
const field = linked.object_type === "projects" ? "parent_id" : "project_id";
|
|
17100
|
+
if (!["projects", "tasks", "plans", "task_lists"].includes(linked.object_type) || linked.payload[field] !== row.object_id)
|
|
17101
|
+
continue;
|
|
17102
|
+
if (compare(linked, row) > 0)
|
|
17103
|
+
fail("A newer linked record conflicts with the historical project deletion", 409, linked);
|
|
17104
|
+
if (linked.object_type === "tasks" && (linked.payload.locked_by || linked.payload.runner_id))
|
|
17105
|
+
fail("Active linked task lease requires reconciliation", 409, linked);
|
|
17106
|
+
const detached = { ...linked, payload: { ...linked.payload, [field]: null, updated_at: row.updated_at }, updated_at: row.updated_at };
|
|
17107
|
+
if (linked.object_type === "tasks") {
|
|
17108
|
+
detached.version = version(linked.version) + 1;
|
|
17109
|
+
detached.payload.version = detached.version;
|
|
17110
|
+
}
|
|
17111
|
+
current.set(id, detached);
|
|
17112
|
+
receipt(linked, linked, detached, "detached");
|
|
17113
|
+
}
|
|
17114
|
+
current.set(key(row), row);
|
|
17115
|
+
receipt(row, before, row, "deleted");
|
|
17116
|
+
}
|
|
17117
|
+
validateGraph(current);
|
|
17118
|
+
for (const [id, row] of current) {
|
|
17119
|
+
if (Date.now() > deadline)
|
|
17120
|
+
fail("Atomic migration exceeded its transaction time budget", 503);
|
|
17121
|
+
const before = original.get(id);
|
|
17122
|
+
if (before && digest(before) === digest(row))
|
|
17123
|
+
continue;
|
|
17124
|
+
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]);
|
|
17125
|
+
}
|
|
17126
|
+
const result = { schema_version: 1, operation_id: request.operation_id, snapshot_hash: request.snapshot_hash, authority: request.expected_authority, status: "complete", records: receipts };
|
|
17127
|
+
const changedKeys = new Set(receipts.map((row) => key(row)));
|
|
17128
|
+
const evidence = { request_hash: requestHash, request, receipt: result, before: [...original.values()].filter((row) => changedKeys.has(key(row))) };
|
|
17129
|
+
if (Date.now() > deadline)
|
|
17130
|
+
fail("Atomic migration exceeded its transaction time budget", 503);
|
|
17131
|
+
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]);
|
|
17132
|
+
return result;
|
|
17133
|
+
});
|
|
17134
|
+
} };
|
|
17135
|
+
}
|
|
17136
|
+
|
|
15226
17137
|
// src/storage/postgres-adapter.ts
|
|
15227
17138
|
init_types();
|
|
15228
|
-
import { randomUUID as
|
|
17139
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
15229
17140
|
init_creator_identity();
|
|
15230
17141
|
|
|
15231
17142
|
// src/lib/instant-compare.ts
|
|
@@ -15269,8 +17180,8 @@ function sqliteJulianDay(value) {
|
|
|
15269
17180
|
const ms = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
15270
17181
|
return (ms + micros / 1000) / 86400000 + 2440587.5 - offsetMinutes / 1440;
|
|
15271
17182
|
}
|
|
15272
|
-
function changedSinceStampNewer(
|
|
15273
|
-
const stampJd = sqliteJulianDay(
|
|
17183
|
+
function changedSinceStampNewer(stamp2, since) {
|
|
17184
|
+
const stampJd = sqliteJulianDay(stamp2);
|
|
15274
17185
|
if (stampJd === null)
|
|
15275
17186
|
return true;
|
|
15276
17187
|
const sinceJd = sqliteJulianDay(since);
|
|
@@ -15504,6 +17415,10 @@ class PostgresTodosSyncStore {
|
|
|
15504
17415
|
}
|
|
15505
17416
|
}
|
|
15506
17417
|
async pushSnapshot(snapshot, context = {}) {
|
|
17418
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines"))
|
|
17419
|
+
throw new Error("Machine tombstones require explicit registry lifecycle operations");
|
|
17420
|
+
if (snapshot.machines !== undefined)
|
|
17421
|
+
validateMachines(snapshot.machines);
|
|
15507
17422
|
const routingErrors = validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists);
|
|
15508
17423
|
if (routingErrors.length > 0) {
|
|
15509
17424
|
throw new Error(`Invalid snapshot routing metadata: ${routingErrors.join("; ")}`);
|
|
@@ -15526,6 +17441,14 @@ class PostgresTodosSyncStore {
|
|
|
15526
17441
|
throw new ResourceConflictError("SNAPSHOT_DESTINATION_CONFLICT", `Snapshot routing conflicts with destination: ${destinationErrors.join("; ")}`);
|
|
15527
17442
|
}
|
|
15528
17443
|
const result = { records: 0, objectTypes: {} };
|
|
17444
|
+
if (snapshot.machines?.length) {
|
|
17445
|
+
if (!this.client.transaction)
|
|
17446
|
+
throw new Error("Machine snapshot migration requires transaction(callback)");
|
|
17447
|
+
const registry = createPostgresMachineRegistry({ query: client.query.bind(client), transaction: (fn) => fn(client) }, this.service, this.tableName, async () => {});
|
|
17448
|
+
const receipt = await registry.execute({ action: "import", machines: snapshot.machines });
|
|
17449
|
+
result.records += receipt.inserted + receipt.skipped;
|
|
17450
|
+
result.objectTypes.machines = receipt.inserted + receipt.skipped;
|
|
17451
|
+
}
|
|
15529
17452
|
const sourceMachineId = context.requestId ?? this.sourceMachineId ?? null;
|
|
15530
17453
|
for (const entry of snapshotEntries(snapshot)) {
|
|
15531
17454
|
if (entry.deletedAt === null)
|
|
@@ -15689,6 +17612,7 @@ function rowsToSnapshot(rows) {
|
|
|
15689
17612
|
tasks: [],
|
|
15690
17613
|
projects: [],
|
|
15691
17614
|
projectMachinePaths: [],
|
|
17615
|
+
machines: [],
|
|
15692
17616
|
plans: [],
|
|
15693
17617
|
agents: [],
|
|
15694
17618
|
taskLists: [],
|
|
@@ -15700,6 +17624,12 @@ function rowsToSnapshot(rows) {
|
|
|
15700
17624
|
for (const row of rows) {
|
|
15701
17625
|
const payload = payloadRecord(row.payload);
|
|
15702
17626
|
const deletedAt = stringValue(row.deleted_at);
|
|
17627
|
+
if (row.object_type === "machines") {
|
|
17628
|
+
if (deletedAt)
|
|
17629
|
+
throw new Error("Snapshot contains retired machine identities; use the authoritative registry instead of silently discarding tombstones");
|
|
17630
|
+
snapshot.machines.push(payload);
|
|
17631
|
+
continue;
|
|
17632
|
+
}
|
|
15703
17633
|
if (deletedAt) {
|
|
15704
17634
|
snapshot.tombstones ??= [];
|
|
15705
17635
|
snapshot.tombstones.push({
|
|
@@ -15760,12 +17690,12 @@ init_redaction();
|
|
|
15760
17690
|
init_task_parent_integrity();
|
|
15761
17691
|
|
|
15762
17692
|
// src/task-manifest/canonical.ts
|
|
15763
|
-
import { createHash as
|
|
17693
|
+
import { createHash as createHash6 } from "crypto";
|
|
15764
17694
|
function canonicalize2(value) {
|
|
15765
17695
|
if (Array.isArray(value))
|
|
15766
17696
|
return value.map(canonicalize2);
|
|
15767
17697
|
if (value !== null && typeof value === "object") {
|
|
15768
|
-
return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([
|
|
17698
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry2]) => entry2 !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key2, entry2]) => [key2, canonicalize2(entry2)]));
|
|
15769
17699
|
}
|
|
15770
17700
|
return value;
|
|
15771
17701
|
}
|
|
@@ -15773,10 +17703,10 @@ function canonicalJson(value) {
|
|
|
15773
17703
|
return JSON.stringify(canonicalize2(value));
|
|
15774
17704
|
}
|
|
15775
17705
|
function canonicalDigest(value) {
|
|
15776
|
-
return
|
|
17706
|
+
return createHash6("sha256").update(canonicalJson(value)).digest("hex");
|
|
15777
17707
|
}
|
|
15778
17708
|
function deterministicUuid(namespace, ...parts) {
|
|
15779
|
-
const bytes =
|
|
17709
|
+
const bytes = createHash6("sha256").update([namespace, ...parts].join("\x1F")).digest().subarray(0, 16);
|
|
15780
17710
|
bytes[6] = bytes[6] & 15 | 80;
|
|
15781
17711
|
bytes[8] = bytes[8] & 63 | 128;
|
|
15782
17712
|
const hex = bytes.toString("hex");
|
|
@@ -15836,13 +17766,14 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15836
17766
|
find: (ref) => findGitRefs(ref, store)
|
|
15837
17767
|
},
|
|
15838
17768
|
projects: {
|
|
15839
|
-
create: (input, context) => createProject2(input,
|
|
17769
|
+
create: (input, context) => store.withDependencyGraphTransaction((scoped) => createProject2(input, scoped, context)),
|
|
15840
17770
|
get: (id) => store.get("projects", id),
|
|
15841
17771
|
getByPath: async (path) => (await store.list("projects")).find((project) => project.path === path) ?? null,
|
|
15842
17772
|
list: async () => (await store.list("projects")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
15843
|
-
update: (id, input) => updateProject2(id, input,
|
|
17773
|
+
update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateProject2(id, input, scoped)),
|
|
15844
17774
|
rename: (id, input, context) => store.renameProject(id, input.new_slug, input.name, context),
|
|
15845
|
-
delete: (id, context) => store.
|
|
17775
|
+
delete: async (id, context) => (await store.deleteProjectPreserving(id, false, context)).deleted,
|
|
17776
|
+
deletePreserving: (id, force, context, requireCompletedTasks) => store.deleteProjectPreserving(id, force, context, requireCompletedTasks)
|
|
15846
17777
|
},
|
|
15847
17778
|
plans: {
|
|
15848
17779
|
create: (input, context) => createPlan2(input, store, context),
|
|
@@ -15851,6 +17782,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15851
17782
|
update: (id, input) => updatePlan2(id, input, store),
|
|
15852
17783
|
completeAtRevision: (id, expectedUpdatedAt, context) => store.completePlanAtRevision(id, expectedUpdatedAt, context),
|
|
15853
17784
|
delete: (id, context) => store.deletePlan(id, context),
|
|
17785
|
+
deletePreserving: (id, force, context) => store.deletePlanPreserving(id, force, context),
|
|
15854
17786
|
addComment: (input, context) => addPlanComment2(input, store, context),
|
|
15855
17787
|
getComments: async (planId) => {
|
|
15856
17788
|
const pages = [];
|
|
@@ -15875,7 +17807,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15875
17807
|
apply: (input, context) => store.applyPlanProjectLink(input, context),
|
|
15876
17808
|
rollback: (input, context) => store.rollbackPlanProjectLink(input, context),
|
|
15877
17809
|
getReceipt: (receiptId) => store.getPlanProjectLinkReceipt(receiptId),
|
|
15878
|
-
getReceiptByIdempotencyKey: (
|
|
17810
|
+
getReceiptByIdempotencyKey: (key2) => store.getPlanProjectLinkReceiptByIdempotencyKey(key2)
|
|
15879
17811
|
},
|
|
15880
17812
|
agents: {
|
|
15881
17813
|
register: (input, context) => registerAgent2(input, store, context),
|
|
@@ -15888,17 +17820,27 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15888
17820
|
},
|
|
15889
17821
|
taskLists: {
|
|
15890
17822
|
create: (input, context) => createTaskList2(input, store, context),
|
|
15891
|
-
get: (id) =>
|
|
15892
|
-
|
|
15893
|
-
|
|
15894
|
-
|
|
15895
|
-
|
|
17823
|
+
get: async (id) => {
|
|
17824
|
+
const list = await store.get("task_lists", id);
|
|
17825
|
+
return list ? withTaskListStatus(list) : null;
|
|
17826
|
+
},
|
|
17827
|
+
getBySlug: async (slug, projectId) => {
|
|
17828
|
+
const list = (await store.list("task_lists")).find((list2) => list2.slug === slug && (projectId === undefined || list2.project_id === projectId));
|
|
17829
|
+
return list ? withTaskListStatus(list) : null;
|
|
17830
|
+
},
|
|
17831
|
+
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)),
|
|
17832
|
+
update: (id, input) => store.withDependencyGraphTransaction((scoped) => updateTaskList2(id, input, scoped)).then(withTaskListStatus),
|
|
17833
|
+
delete: async (id, context) => (await store.deleteTaskListPreserving(id, false, context)).deleted,
|
|
17834
|
+
deletePreserving: (id, force, context) => store.deleteTaskListPreserving(id, force, context)
|
|
15896
17835
|
},
|
|
15897
17836
|
templates: {
|
|
17837
|
+
updateWithHistory: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
|
|
17838
|
+
history: (id) => store.withTemplateTransaction((scoped) => readTemplateHistory(id, scoped)),
|
|
17839
|
+
initialize: (context) => store.withTemplateTransaction((scoped) => initializeTemplates(scoped, context)),
|
|
15898
17840
|
create: (input, context) => createTemplate2(input, store, context),
|
|
15899
17841
|
get: (id) => store.get("templates", id),
|
|
15900
17842
|
list: async () => (await store.list("templates")).sort((a, b) => a.name.localeCompare(b.name)),
|
|
15901
|
-
update: (id, input) => updateTemplate2(id, input,
|
|
17843
|
+
update: (id, input) => store.withTemplateTransaction((scoped) => updateTemplate2(id, input, scoped)),
|
|
15902
17844
|
delete: (id, context) => deleteTemplate2(id, store, context),
|
|
15903
17845
|
getWithTasks: async (id) => {
|
|
15904
17846
|
const template = await store.get("templates", id);
|
|
@@ -15932,10 +17874,12 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
15932
17874
|
getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
|
|
15933
17875
|
getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
|
|
15934
17876
|
},
|
|
17877
|
+
machines: createPostgresMachineRegistry(options.client, options.service ?? "todos", options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, () => store.ensureSchema()),
|
|
17878
|
+
atomicProjectMigration: createAtomicProjectMigration({ client: options.client, table: options.tableName ?? DEFAULT_TODOS_POSTGRES_SYNC_TABLE, service: options.service ?? "todos", ensureSchema: () => store.ensureSchema() }),
|
|
15935
17879
|
sync: {
|
|
15936
17880
|
getTasksChangedSince: (since, filters) => getChangedSince(since, filters, store),
|
|
15937
17881
|
exportSnapshot: () => exportSnapshot(store),
|
|
15938
|
-
importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context)
|
|
17882
|
+
importSnapshot: (snapshot, context) => importSnapshot(snapshot, store, context, adapter.machines)
|
|
15939
17883
|
},
|
|
15940
17884
|
integrity: {
|
|
15941
17885
|
report: () => store.integrityReport()
|
|
@@ -15952,6 +17896,7 @@ class PostgresJsonRecordStore {
|
|
|
15952
17896
|
tableName;
|
|
15953
17897
|
cursorTableName;
|
|
15954
17898
|
schemaReady = null;
|
|
17899
|
+
projectIntegrityLocked = false;
|
|
15955
17900
|
constructor(options) {
|
|
15956
17901
|
this.options = options;
|
|
15957
17902
|
this.service = options.service ?? "todos";
|
|
@@ -15962,6 +17907,43 @@ class PostgresJsonRecordStore {
|
|
|
15962
17907
|
machineId(context) {
|
|
15963
17908
|
return context?.requestId ?? this.sourceMachineId ?? null;
|
|
15964
17909
|
}
|
|
17910
|
+
async withDependencyGraphTransaction(fn) {
|
|
17911
|
+
await this.ensureSchema();
|
|
17912
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
17913
|
+
const scoped = new PostgresJsonRecordStore({ ...this.options, client });
|
|
17914
|
+
scoped.schemaReady = Promise.resolve();
|
|
17915
|
+
scoped.projectIntegrityLocked = true;
|
|
17916
|
+
return fn(scoped);
|
|
17917
|
+
});
|
|
17918
|
+
}
|
|
17919
|
+
async withTemplateTransaction(fn) {
|
|
17920
|
+
await this.ensureSchema();
|
|
17921
|
+
const transaction = this.options.client.transaction;
|
|
17922
|
+
if (!transaction)
|
|
17923
|
+
throw new Error("TEMPLATE_ATOMICITY_UNAVAILABLE: upgrade the Todos storage backend");
|
|
17924
|
+
return transaction(async (client) => {
|
|
17925
|
+
await client.query("/* todos:template-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':template-integrity', 0))", [this.service]);
|
|
17926
|
+
const scoped = new PostgresJsonRecordStore({ ...this.options, client });
|
|
17927
|
+
scoped.schemaReady = Promise.resolve();
|
|
17928
|
+
return fn(scoped);
|
|
17929
|
+
});
|
|
17930
|
+
}
|
|
17931
|
+
async lockTemplate(id) {
|
|
17932
|
+
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]);
|
|
17933
|
+
}
|
|
17934
|
+
async persistTemplateRevision(template, previousVersion) {
|
|
17935
|
+
const result = await this.options.client.query(`/* todos:template-version-cas */ UPDATE ${this.tableName}
|
|
17936
|
+
SET payload = $3::jsonb,
|
|
17937
|
+
updated_at = GREATEST(clock_timestamp(), COALESCE(updated_at, 'epoch'::timestamptz) + interval '1 microsecond'),
|
|
17938
|
+
version = $5
|
|
17939
|
+
WHERE service = $1 AND object_type = 'templates' AND object_id = $2
|
|
17940
|
+
AND deleted_at IS NULL AND (payload->>'version')::integer = $4
|
|
17941
|
+
AND COALESCE(version, 1) = $4
|
|
17942
|
+
RETURNING payload`, [this.service, template.id, jsonbParam(template), previousVersion, template.version]);
|
|
17943
|
+
if (result.rows.length !== 1)
|
|
17944
|
+
throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template revision write was refused; no history was committed");
|
|
17945
|
+
return payloadRecord2(result.rows[0].payload);
|
|
17946
|
+
}
|
|
15965
17947
|
async ensureSchema() {
|
|
15966
17948
|
if (!this.schemaReady) {
|
|
15967
17949
|
this.schemaReady = (async () => {
|
|
@@ -16148,6 +18130,8 @@ class PostgresJsonRecordStore {
|
|
|
16148
18130
|
return "1=0";
|
|
16149
18131
|
return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
|
|
16150
18132
|
};
|
|
18133
|
+
if (filter.include_archived === false)
|
|
18134
|
+
conds.push(`(payload->>'archived_at' IS NULL)`);
|
|
16151
18135
|
if (filter.ids)
|
|
16152
18136
|
conds.push(inClause("payload->>'id'", filter.ids));
|
|
16153
18137
|
if (filter.project_id !== undefined)
|
|
@@ -16316,6 +18300,32 @@ class PostgresJsonRecordStore {
|
|
|
16316
18300
|
throw new Error("Invalid task-list project scope \u2014 project_id must be null, missing, or a non-empty string");
|
|
16317
18301
|
}
|
|
16318
18302
|
}
|
|
18303
|
+
const projectTypes = new Set(["projects", "tasks", "plans", "task_lists"]);
|
|
18304
|
+
const reference = type === "projects" ? value.parent_id : value.project_id;
|
|
18305
|
+
const planReference = value.plan_id;
|
|
18306
|
+
const needsPlanFence = type === "tasks" && typeof planReference === "string" && planReference.length > 0;
|
|
18307
|
+
const listReference = value.task_list_id;
|
|
18308
|
+
const needsListFence = (type === "tasks" || type === "plans") && typeof listReference === "string" && listReference.length > 0;
|
|
18309
|
+
const needsProjectFence = needsListFence || needsPlanFence || type === "projects" || projectTypes.has(type) && typeof reference === "string" && reference.length > 0;
|
|
18310
|
+
if (needsProjectFence && !this.projectIntegrityLocked) {
|
|
18311
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.upsert(type, value, context));
|
|
18312
|
+
}
|
|
18313
|
+
if (needsListFence)
|
|
18314
|
+
await this.assertTaskListMembership(type, value.id, listReference);
|
|
18315
|
+
if (needsPlanFence) {
|
|
18316
|
+
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]);
|
|
18317
|
+
if (!plan.rows.length)
|
|
18318
|
+
throw new PlanNotFoundError(planReference);
|
|
18319
|
+
}
|
|
18320
|
+
if (projectTypes.has(type)) {
|
|
18321
|
+
if (typeof reference === "string" && reference)
|
|
18322
|
+
await this.assertLiveProject(reference);
|
|
18323
|
+
if (type === "projects") {
|
|
18324
|
+
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]);
|
|
18325
|
+
if (prior.rows[0]?.deleted_at)
|
|
18326
|
+
throw new ProjectNotFoundError(value.id);
|
|
18327
|
+
}
|
|
18328
|
+
}
|
|
16319
18329
|
await this.ensureSchema();
|
|
16320
18330
|
const updatedAt = stringValue2(value.updated_at) ?? stringValue2(value.created_at) ?? new Date().toISOString();
|
|
16321
18331
|
let result;
|
|
@@ -16397,10 +18407,13 @@ class PostgresJsonRecordStore {
|
|
|
16397
18407
|
if (planIds.length === 0 && !parentGuard)
|
|
16398
18408
|
return this.upsert("tasks", value, context);
|
|
16399
18409
|
await this.ensureSchema();
|
|
16400
|
-
if (
|
|
18410
|
+
if (!queryClient) {
|
|
16401
18411
|
return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
|
|
16402
18412
|
}
|
|
16403
18413
|
const client = queryClient ?? this.options.client;
|
|
18414
|
+
if (value.project_id)
|
|
18415
|
+
await this.assertLiveProject(value.project_id, client);
|
|
18416
|
+
await this.assertTaskListMembership("tasks", value.id, value.task_list_id, client);
|
|
16404
18417
|
const updatedAt = value.updated_at;
|
|
16405
18418
|
const targetPlanId = value.plan_id;
|
|
16406
18419
|
const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
|
|
@@ -16535,8 +18548,11 @@ class PostgresJsonRecordStore {
|
|
|
16535
18548
|
}
|
|
16536
18549
|
return payloadRecord2(row.payload);
|
|
16537
18550
|
}
|
|
16538
|
-
async updatePlanWithProjectLinkGuard(value, context = {}) {
|
|
18551
|
+
async updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context = {}) {
|
|
18552
|
+
if (!this.projectIntegrityLocked)
|
|
18553
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.updatePlanWithProjectLinkGuard(value, expectedUpdatedAt, context));
|
|
16539
18554
|
await this.ensureSchema();
|
|
18555
|
+
await this.assertTaskListMembership("plans", value.id, value.task_list_id);
|
|
16540
18556
|
const result = await this.options.client.query(`/* todos:plan-update-project-link-guard */ WITH locked_plan AS MATERIALIZED (
|
|
16541
18557
|
SELECT payload FROM ${this.tableName}
|
|
16542
18558
|
WHERE service = $1 AND object_type = 'plans' AND object_id = $2 AND deleted_at IS NULL
|
|
@@ -16555,19 +18571,24 @@ class PostgresJsonRecordStore {
|
|
|
16555
18571
|
version = COALESCE(r.version, 0) + 1
|
|
16556
18572
|
FROM locked_plan
|
|
16557
18573
|
WHERE r.service = $1 AND r.object_type = 'plans' AND r.object_id = $2 AND r.deleted_at IS NULL
|
|
18574
|
+
AND locked_plan.payload->>'updated_at' = $6
|
|
16558
18575
|
RETURNING r.payload
|
|
16559
18576
|
)
|
|
16560
18577
|
SELECT EXISTS (SELECT 1 FROM locked_plan) AS plan_found,
|
|
18578
|
+
(SELECT payload->>'updated_at' FROM locked_plan) AS current_revision,
|
|
16561
18579
|
(SELECT payload FROM stored) AS payload`, [
|
|
16562
18580
|
this.service,
|
|
16563
18581
|
value.id,
|
|
16564
18582
|
jsonbParam(value),
|
|
16565
18583
|
value.updated_at,
|
|
16566
|
-
context.requestId ?? this.sourceMachineId ?? null
|
|
18584
|
+
context.requestId ?? this.sourceMachineId ?? null,
|
|
18585
|
+
expectedUpdatedAt
|
|
16567
18586
|
]);
|
|
16568
18587
|
const row = result.rows[0];
|
|
16569
|
-
if (!row?.plan_found
|
|
18588
|
+
if (!row?.plan_found)
|
|
16570
18589
|
throw new PlanNotFoundError(value.id);
|
|
18590
|
+
if (!row.payload)
|
|
18591
|
+
throw new PlanRevisionConflictError(value.id, expectedUpdatedAt, row.current_revision);
|
|
16571
18592
|
return payloadRecord2(row.payload);
|
|
16572
18593
|
}
|
|
16573
18594
|
async completePlanAtRevision(id, expectedUpdatedAt, context = {}) {
|
|
@@ -16841,6 +18862,71 @@ class PostgresJsonRecordStore {
|
|
|
16841
18862
|
return null;
|
|
16842
18863
|
return Number(row.counter);
|
|
16843
18864
|
}
|
|
18865
|
+
async assertTaskListMembership(type, id, target, client = this.options.client) {
|
|
18866
|
+
if (target === undefined || target === null || target === "")
|
|
18867
|
+
return;
|
|
18868
|
+
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]);
|
|
18869
|
+
if (prior.rows[0]?.task_list_id === target)
|
|
18870
|
+
return;
|
|
18871
|
+
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]);
|
|
18872
|
+
if (!list.rows.length)
|
|
18873
|
+
throw new TaskListNotFoundError(String(target));
|
|
18874
|
+
}
|
|
18875
|
+
async deleteTaskListPreserving(id, force, context = {}) {
|
|
18876
|
+
await this.ensureSchema();
|
|
18877
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
18878
|
+
const receipt = { schema_version: 1, task_list_id: id, deleted: false, detached_task_ids: [], detached_plan_ids: [], detached_tasks: 0, detached_plans: 0 };
|
|
18879
|
+
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]);
|
|
18880
|
+
if (!target.rows.length)
|
|
18881
|
+
return receipt;
|
|
18882
|
+
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]);
|
|
18883
|
+
if (related.rows.length && !force)
|
|
18884
|
+
throw new ResourceConflictError("TASK_LIST_NOT_EMPTY", "Task list has linked records; force confirms detaching them without deleting content or history");
|
|
18885
|
+
const timestamp2 = new Date([...target.rows, ...related.rows].reduce((latest, row) => Math.max(latest, Date.parse(String(row.updated_at)) + 1), Date.now())).toISOString();
|
|
18886
|
+
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)]);
|
|
18887
|
+
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)]);
|
|
18888
|
+
receipt.deleted = true;
|
|
18889
|
+
receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
|
|
18890
|
+
receipt.detached_plan_ids = related.rows.filter((row) => row.object_type === "plans").map((row) => row.object_id);
|
|
18891
|
+
receipt.detached_tasks = receipt.detached_task_ids.length;
|
|
18892
|
+
receipt.detached_plans = receipt.detached_plan_ids.length;
|
|
18893
|
+
return receipt;
|
|
18894
|
+
});
|
|
18895
|
+
}
|
|
18896
|
+
async assertLiveProject(id, client = this.options.client) {
|
|
18897
|
+
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]);
|
|
18898
|
+
if (!result.rows.length)
|
|
18899
|
+
throw new ProjectNotFoundError(id);
|
|
18900
|
+
}
|
|
18901
|
+
async deleteProjectPreserving(id, force, context = {}, requireCompletedTasks = false) {
|
|
18902
|
+
await this.ensureSchema();
|
|
18903
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
18904
|
+
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]);
|
|
18905
|
+
const receipt = { schema_version: 1, project_id: id, deleted: false, preserved_tasks: 0, preserved_plans: 0, detached_task_lists: 0, detached_child_projects: 0 };
|
|
18906
|
+
if (!project.rows.length)
|
|
18907
|
+
return receipt;
|
|
18908
|
+
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]);
|
|
18909
|
+
if (requireCompletedTasks && related.rows.some((row) => row.object_type === "tasks" && row.status !== "completed" && row.status !== "cancelled"))
|
|
18910
|
+
throw new ResourceConflictError("PROJECT_INCOMPLETE", "Project has incomplete tasks; deregistration refused");
|
|
18911
|
+
if (related.rows.length && !force)
|
|
18912
|
+
throw new ResourceConflictError("PROJECT_NOT_EMPTY", "Project has linked records; force confirms detaching them without deleting their content");
|
|
18913
|
+
const timestamp2 = new Date().toISOString();
|
|
18914
|
+
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]);
|
|
18915
|
+
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]);
|
|
18916
|
+
receipt.deleted = true;
|
|
18917
|
+
for (const row of related.rows) {
|
|
18918
|
+
if (row.object_type === "tasks")
|
|
18919
|
+
receipt.preserved_tasks++;
|
|
18920
|
+
else if (row.object_type === "plans")
|
|
18921
|
+
receipt.preserved_plans++;
|
|
18922
|
+
else if (row.object_type === "task_lists")
|
|
18923
|
+
receipt.detached_task_lists++;
|
|
18924
|
+
else
|
|
18925
|
+
receipt.detached_child_projects++;
|
|
18926
|
+
}
|
|
18927
|
+
return receipt;
|
|
18928
|
+
});
|
|
18929
|
+
}
|
|
16844
18930
|
async delete(type, id, context = {}) {
|
|
16845
18931
|
await this.ensureSchema();
|
|
16846
18932
|
const existing = await this.get(type, id);
|
|
@@ -16945,7 +19031,7 @@ class PostgresJsonRecordStore {
|
|
|
16945
19031
|
}
|
|
16946
19032
|
return result.rows[0] ? assertPlanProjectLinkReceipt(result.rows[0].payload) : null;
|
|
16947
19033
|
}
|
|
16948
|
-
async currentPlanProjectLinkResult(receipt, action) {
|
|
19034
|
+
async currentPlanProjectLinkResult(receipt, action, previouslyCommitted = false) {
|
|
16949
19035
|
const [plan, project, tasks] = await Promise.all([
|
|
16950
19036
|
this.get("plans", receipt.plan_id),
|
|
16951
19037
|
this.get("projects", receipt.project_id),
|
|
@@ -16953,11 +19039,14 @@ class PostgresJsonRecordStore {
|
|
|
16953
19039
|
]);
|
|
16954
19040
|
const sortedTasks2 = tasks.sort((left, right) => left.id.localeCompare(right.id));
|
|
16955
19041
|
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 });
|
|
19042
|
+
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
19043
|
}
|
|
16958
19044
|
return { mode: "apply", action, plan, project, tasks: sortedTasks2, receipt };
|
|
16959
19045
|
}
|
|
16960
19046
|
async applyPlanProjectLink(input, context = {}) {
|
|
19047
|
+
if (!this.projectIntegrityLocked) {
|
|
19048
|
+
return this.withDependencyGraphTransaction((scoped) => scoped.applyPlanProjectLink(input, context));
|
|
19049
|
+
}
|
|
16961
19050
|
await this.ensureSchema();
|
|
16962
19051
|
const existing = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
16963
19052
|
if (existing) {
|
|
@@ -16968,7 +19057,7 @@ class PostgresJsonRecordStore {
|
|
|
16968
19057
|
if (rolledBack) {
|
|
16969
19058
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The accepted plan-project link has already been rolled back", { receipt_id: input.receipt_id });
|
|
16970
19059
|
}
|
|
16971
|
-
return this.currentPlanProjectLinkResult(existing, "already_linked");
|
|
19060
|
+
return this.currentPlanProjectLinkResult(existing, "already_linked", true);
|
|
16972
19061
|
}
|
|
16973
19062
|
const [plan, project, tasks, scopedPlans] = await Promise.all([
|
|
16974
19063
|
this.get("plans", input.plan_id),
|
|
@@ -17110,7 +19199,7 @@ class PostgresJsonRecordStore {
|
|
|
17110
19199
|
if (isPostgresUniqueViolation(error)) {
|
|
17111
19200
|
const raced = await this.getPlanProjectLinkReceipt(input.receipt_id);
|
|
17112
19201
|
if (raced && raced.plan_id === input.plan_id && raced.project_id === input.project_id) {
|
|
17113
|
-
return this.currentPlanProjectLinkResult(raced, "already_linked");
|
|
19202
|
+
return this.currentPlanProjectLinkResult(raced, "already_linked", true);
|
|
17114
19203
|
}
|
|
17115
19204
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key raced with a different plan-project link", { idempotency_key: input.idempotency_key });
|
|
17116
19205
|
}
|
|
@@ -17133,7 +19222,7 @@ class PostgresJsonRecordStore {
|
|
|
17133
19222
|
if (accepted.plan_id !== plan.id || accepted.project_id !== project.id) {
|
|
17134
19223
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_IDEMPOTENCY_CONFLICT", "The idempotency key was accepted for a different target");
|
|
17135
19224
|
}
|
|
17136
|
-
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked");
|
|
19225
|
+
return this.currentPlanProjectLinkResult(accepted, alreadyLinked ? "already_linked" : "linked", row.existing_receipt != null);
|
|
17137
19226
|
}
|
|
17138
19227
|
async rollbackPlanProjectLink(input, context = {}) {
|
|
17139
19228
|
await this.ensureSchema();
|
|
@@ -17256,6 +19345,28 @@ class PostgresJsonRecordStore {
|
|
|
17256
19345
|
throw new PlanProjectLinkError("PLAN_PROJECT_LINK_ROLLBACK_CONFLICT", "Rollback did not produce an immutable receipt");
|
|
17257
19346
|
return accepted;
|
|
17258
19347
|
}
|
|
19348
|
+
async deletePlanPreserving(id, force, context = {}) {
|
|
19349
|
+
await this.ensureSchema();
|
|
19350
|
+
return this.withTaskParentIntegrityTransaction(async (client) => {
|
|
19351
|
+
const receipt = { schema_version: 1, plan_id: id, deleted: false, detached_task_ids: [], detached_task_list_ids: [], detached_tasks: 0, detached_task_lists: 0 };
|
|
19352
|
+
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]);
|
|
19353
|
+
if (!target.rows.length)
|
|
19354
|
+
return receipt;
|
|
19355
|
+
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]);
|
|
19356
|
+
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]);
|
|
19357
|
+
if ((related.rows.length || notes.rows.length) && !force)
|
|
19358
|
+
throw new ResourceConflictError("PLAN_NOT_EMPTY", "Plan has linked records; force confirms detaching them while preserving content and history");
|
|
19359
|
+
const timestamp2 = new Date().toISOString();
|
|
19360
|
+
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)]);
|
|
19361
|
+
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)]);
|
|
19362
|
+
receipt.deleted = true;
|
|
19363
|
+
receipt.detached_task_ids = related.rows.filter((row) => row.object_type === "tasks").map((row) => row.object_id);
|
|
19364
|
+
receipt.detached_task_list_ids = related.rows.filter((row) => row.object_type === "task_lists").map((row) => row.object_id);
|
|
19365
|
+
receipt.detached_tasks = receipt.detached_task_ids.length;
|
|
19366
|
+
receipt.detached_task_lists = receipt.detached_task_list_ids.length;
|
|
19367
|
+
return receipt;
|
|
19368
|
+
});
|
|
19369
|
+
}
|
|
17259
19370
|
async deletePlan(id, context = {}) {
|
|
17260
19371
|
await this.ensureSchema();
|
|
17261
19372
|
const timestamp2 = new Date().toISOString();
|
|
@@ -17302,6 +19413,8 @@ class PostgresJsonRecordStore {
|
|
|
17302
19413
|
}
|
|
17303
19414
|
async tombstone(tombstone, context = {}) {
|
|
17304
19415
|
await this.ensureSchema();
|
|
19416
|
+
if (tombstone.object_type === "atomic_project_migrations")
|
|
19417
|
+
throw new Error("Migration evidence is immutable");
|
|
17305
19418
|
const deletedAt = stringValue2(tombstone.deleted_at) ?? new Date().toISOString();
|
|
17306
19419
|
const updatedAt = stringValue2(tombstone.updated_at) ?? deletedAt;
|
|
17307
19420
|
const existing = await this.clock(tombstone.object_type, tombstone.object_id);
|
|
@@ -17359,7 +19472,7 @@ class PostgresJsonRecordStore {
|
|
|
17359
19472
|
}
|
|
17360
19473
|
async function createTask2(input, store, context) {
|
|
17361
19474
|
const timestamp2 = new Date().toISOString();
|
|
17362
|
-
const taskId =
|
|
19475
|
+
const taskId = randomUUID4();
|
|
17363
19476
|
await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
|
|
17364
19477
|
const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
|
|
17365
19478
|
const requestedProjectId = input.project_id ?? context?.projectId ?? null;
|
|
@@ -17581,42 +19694,46 @@ function dependencyId(taskId, dependsOn) {
|
|
|
17581
19694
|
return `${taskId}::${dependsOn}`;
|
|
17582
19695
|
}
|
|
17583
19696
|
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
|
-
|
|
19697
|
+
return store.withDependencyGraphTransaction(async (store2) => {
|
|
19698
|
+
if (taskId === dependsOn)
|
|
19699
|
+
throw new Error("A task cannot depend on itself");
|
|
19700
|
+
if (!await store2.get("tasks", taskId))
|
|
19701
|
+
throw new Error(`Task not found: ${taskId}`);
|
|
19702
|
+
if (!await store2.get("tasks", dependsOn))
|
|
19703
|
+
throw new Error(`Task not found: ${dependsOn}`);
|
|
19704
|
+
const edges = await store2.list("dependencies");
|
|
19705
|
+
const adjacency = new Map;
|
|
19706
|
+
for (const edge of edges) {
|
|
19707
|
+
if (!adjacency.has(edge.task_id))
|
|
19708
|
+
adjacency.set(edge.task_id, []);
|
|
19709
|
+
adjacency.get(edge.task_id).push(edge.depends_on);
|
|
19710
|
+
}
|
|
19711
|
+
const queue = [dependsOn];
|
|
19712
|
+
const seen = new Set;
|
|
19713
|
+
while (queue.length) {
|
|
19714
|
+
const node = queue.shift();
|
|
19715
|
+
if (node === taskId)
|
|
19716
|
+
throw new Error(`Adding dependency ${taskId} -> ${dependsOn} would create a cycle`);
|
|
19717
|
+
if (seen.has(node))
|
|
19718
|
+
continue;
|
|
19719
|
+
seen.add(node);
|
|
19720
|
+
for (const next of adjacency.get(node) ?? [])
|
|
19721
|
+
queue.push(next);
|
|
19722
|
+
}
|
|
19723
|
+
const timestamp2 = new Date().toISOString();
|
|
19724
|
+
const record = { id: dependencyId(taskId, dependsOn), task_id: taskId, depends_on: dependsOn, created_at: timestamp2, updated_at: timestamp2 };
|
|
19725
|
+
await store2.upsert("dependencies", record, context);
|
|
19726
|
+
return { task_id: taskId, depends_on: dependsOn };
|
|
19727
|
+
});
|
|
17613
19728
|
}
|
|
17614
19729
|
async function removeDependency2(taskId, dependsOn, store) {
|
|
17615
|
-
|
|
17616
|
-
|
|
17617
|
-
|
|
17618
|
-
|
|
17619
|
-
|
|
19730
|
+
return store.withDependencyGraphTransaction(async (store2) => {
|
|
19731
|
+
const existing = await store2.get("dependencies", dependencyId(taskId, dependsOn));
|
|
19732
|
+
if (!existing)
|
|
19733
|
+
return false;
|
|
19734
|
+
await store2.delete("dependencies", dependencyId(taskId, dependsOn));
|
|
19735
|
+
return true;
|
|
19736
|
+
});
|
|
17620
19737
|
}
|
|
17621
19738
|
async function listDependencies(taskId, store) {
|
|
17622
19739
|
const edges = await store.list("dependencies");
|
|
@@ -17632,7 +19749,7 @@ async function addVerification(input, store, context) {
|
|
|
17632
19749
|
throw new Error(`Task not found: ${input.task_id}`);
|
|
17633
19750
|
const timestamp2 = new Date().toISOString();
|
|
17634
19751
|
const verification = {
|
|
17635
|
-
id:
|
|
19752
|
+
id: randomUUID4(),
|
|
17636
19753
|
task_id: input.task_id,
|
|
17637
19754
|
command: input.command,
|
|
17638
19755
|
status: input.status ?? "unknown",
|
|
@@ -17653,7 +19770,7 @@ async function addCommit(input, store, context) {
|
|
|
17653
19770
|
throw new Error(`Task not found: ${input.task_id}`);
|
|
17654
19771
|
const timestamp2 = new Date().toISOString();
|
|
17655
19772
|
const commit = {
|
|
17656
|
-
id:
|
|
19773
|
+
id: randomUUID4(),
|
|
17657
19774
|
task_id: input.task_id,
|
|
17658
19775
|
sha: input.sha,
|
|
17659
19776
|
message: input.message ?? null,
|
|
@@ -17749,7 +19866,7 @@ async function createProject2(input, store, context) {
|
|
|
17749
19866
|
if (!derivedSlug || !taskListId)
|
|
17750
19867
|
throw new Error("Project name and task-list slug must be non-empty");
|
|
17751
19868
|
const parentId = input.parent_id ?? null;
|
|
17752
|
-
const id =
|
|
19869
|
+
const id = randomUUID4();
|
|
17753
19870
|
if (parentId !== null) {
|
|
17754
19871
|
const parent = await store.get("projects", parentId);
|
|
17755
19872
|
if (!parent)
|
|
@@ -17760,6 +19877,9 @@ async function createProject2(input, store, context) {
|
|
|
17760
19877
|
id,
|
|
17761
19878
|
name: input.name,
|
|
17762
19879
|
path: input.path,
|
|
19880
|
+
status: input.status ?? "active",
|
|
19881
|
+
short_id: input.short_id ?? null,
|
|
19882
|
+
metadata: input.metadata ?? {},
|
|
17763
19883
|
description: input.description ?? null,
|
|
17764
19884
|
task_list_id: taskListId,
|
|
17765
19885
|
task_prefix: input.task_prefix ?? await generateProjectPrefix(input.name, store),
|
|
@@ -17807,6 +19927,9 @@ async function updateProject2(id, input, store) {
|
|
|
17807
19927
|
return store.upsert("projects", updated);
|
|
17808
19928
|
}
|
|
17809
19929
|
async function createPlan2(input, store, context) {
|
|
19930
|
+
const scheduleError = validatePlanSchedule(input);
|
|
19931
|
+
if (scheduleError)
|
|
19932
|
+
throw new Error(scheduleError);
|
|
17810
19933
|
const timestamp2 = new Date().toISOString();
|
|
17811
19934
|
const projectId = input.project_id ?? context?.projectId ?? null;
|
|
17812
19935
|
const slug = await resolvePostgresPlanSlug({
|
|
@@ -17816,7 +19939,7 @@ async function createPlan2(input, store, context) {
|
|
|
17816
19939
|
store
|
|
17817
19940
|
});
|
|
17818
19941
|
return store.upsert("plans", {
|
|
17819
|
-
id:
|
|
19942
|
+
id: randomUUID4(),
|
|
17820
19943
|
slug,
|
|
17821
19944
|
project_id: projectId,
|
|
17822
19945
|
task_list_id: input.task_list_id ?? context?.taskListId ?? null,
|
|
@@ -17824,6 +19947,8 @@ async function createPlan2(input, store, context) {
|
|
|
17824
19947
|
name: input.name,
|
|
17825
19948
|
description: input.description ?? null,
|
|
17826
19949
|
status: input.status ?? "active",
|
|
19950
|
+
...input.start_date !== undefined ? { start_date: input.start_date } : {},
|
|
19951
|
+
...input.end_date !== undefined ? { end_date: input.end_date } : {},
|
|
17827
19952
|
created_at: timestamp2,
|
|
17828
19953
|
updated_at: timestamp2,
|
|
17829
19954
|
machine_id: store.machineId(context),
|
|
@@ -17832,6 +19957,9 @@ async function createPlan2(input, store, context) {
|
|
|
17832
19957
|
}
|
|
17833
19958
|
async function updatePlan2(id, input, store) {
|
|
17834
19959
|
const plan = await requireRecord("plans", id, store);
|
|
19960
|
+
const scheduleError = validatePlanSchedule({ ...plan, ...input });
|
|
19961
|
+
if (scheduleError)
|
|
19962
|
+
throw new Error(scheduleError);
|
|
17835
19963
|
const patch = definedPatch(input);
|
|
17836
19964
|
if (input.slug !== undefined) {
|
|
17837
19965
|
patch.slug = await resolvePostgresPlanSlug({
|
|
@@ -17846,8 +19974,8 @@ async function updatePlan2(id, input, store) {
|
|
|
17846
19974
|
...plan,
|
|
17847
19975
|
...patch,
|
|
17848
19976
|
project_id: plan.project_id,
|
|
17849
|
-
updated_at: new Date().toISOString()
|
|
17850
|
-
});
|
|
19977
|
+
updated_at: new Date(Math.max(Date.now(), Date.parse(plan.updated_at) + 1)).toISOString()
|
|
19978
|
+
}, plan.updated_at);
|
|
17851
19979
|
}
|
|
17852
19980
|
function matchAgentByName(agents, name, options) {
|
|
17853
19981
|
const target = normalizeAgentNameInput(name);
|
|
@@ -17868,7 +19996,7 @@ async function registerAgent2(input, store, context) {
|
|
|
17868
19996
|
}
|
|
17869
19997
|
const timestamp2 = new Date().toISOString();
|
|
17870
19998
|
const agent = {
|
|
17871
|
-
id: existing?.id ??
|
|
19999
|
+
id: existing?.id ?? randomUUID4().slice(0, 8),
|
|
17872
20000
|
name: canonicalName,
|
|
17873
20001
|
description: input.description ?? existing?.description ?? null,
|
|
17874
20002
|
role: input.role ?? existing?.role ?? null,
|
|
@@ -17935,18 +20063,24 @@ async function releaseAgent2(idOrName, sessionId, store, context) {
|
|
|
17935
20063
|
const updated = await store.upsert("agents", { ...agent, session_id: null, last_seen_at: new Date().toISOString() }, context);
|
|
17936
20064
|
return { agent: updated, released: true };
|
|
17937
20065
|
}
|
|
20066
|
+
function withTaskListStatus(list) {
|
|
20067
|
+
return { ...list, status: list.status ?? "active" };
|
|
20068
|
+
}
|
|
17938
20069
|
async function createTaskList2(input, store, context) {
|
|
20070
|
+
if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
|
|
20071
|
+
throw new Error("Invalid task-list status");
|
|
17939
20072
|
const timestamp2 = new Date().toISOString();
|
|
17940
20073
|
const slug = slugifyRaw(input.slug === undefined ? input.name : input.slug);
|
|
17941
20074
|
if (!slug)
|
|
17942
20075
|
throw new Error("Invalid task-list slug \u2014 must be non-empty kebab-case");
|
|
17943
20076
|
return store.upsert("task_lists", {
|
|
17944
|
-
id:
|
|
20077
|
+
id: randomUUID4(),
|
|
17945
20078
|
project_id: input.project_id ?? context?.projectId ?? null,
|
|
17946
20079
|
slug,
|
|
17947
20080
|
name: input.name,
|
|
17948
20081
|
description: input.description ?? null,
|
|
17949
20082
|
metadata: input.metadata ?? {},
|
|
20083
|
+
status: input.status ?? "active",
|
|
17950
20084
|
created_at: timestamp2,
|
|
17951
20085
|
updated_at: timestamp2,
|
|
17952
20086
|
machine_id: store.machineId(context),
|
|
@@ -17954,6 +20088,8 @@ async function createTaskList2(input, store, context) {
|
|
|
17954
20088
|
}, context);
|
|
17955
20089
|
}
|
|
17956
20090
|
async function updateTaskList2(id, input, store) {
|
|
20091
|
+
if (input.status !== undefined && !["active", "completed", "archived"].includes(input.status))
|
|
20092
|
+
throw new Error("Invalid task-list status");
|
|
17957
20093
|
const list = await requireRecord("task_lists", id, store);
|
|
17958
20094
|
const patch = definedPatch(input);
|
|
17959
20095
|
const projectId = input.project_id === "" || input.project_id === null ? null : input.project_id === undefined ? list.project_id : input.project_id;
|
|
@@ -17976,13 +20112,13 @@ async function updateTaskList2(id, input, store) {
|
|
|
17976
20112
|
...list,
|
|
17977
20113
|
...patch,
|
|
17978
20114
|
metadata: input.metadata ?? list.metadata,
|
|
17979
|
-
updated_at: new Date().toISOString()
|
|
20115
|
+
updated_at: new Date(Math.max(Date.now(), Date.parse(list.updated_at) + 1)).toISOString()
|
|
17980
20116
|
});
|
|
17981
20117
|
}
|
|
17982
20118
|
async function createTemplate2(input, store, context) {
|
|
17983
20119
|
const timestamp2 = new Date().toISOString();
|
|
17984
20120
|
const template = {
|
|
17985
|
-
id:
|
|
20121
|
+
id: randomUUID4(),
|
|
17986
20122
|
name: input.name,
|
|
17987
20123
|
title_pattern: input.title_pattern,
|
|
17988
20124
|
description: input.description ?? null,
|
|
@@ -18003,7 +20139,7 @@ async function createTemplate2(input, store, context) {
|
|
|
18003
20139
|
}
|
|
18004
20140
|
function buildTemplateTasks(templateId, inputs, timestamp2) {
|
|
18005
20141
|
return inputs.map((input, position) => ({
|
|
18006
|
-
id:
|
|
20142
|
+
id: randomUUID4(),
|
|
18007
20143
|
template_id: templateId,
|
|
18008
20144
|
position,
|
|
18009
20145
|
title_pattern: input.title_pattern,
|
|
@@ -18021,22 +20157,107 @@ function buildTemplateTasks(templateId, inputs, timestamp2) {
|
|
|
18021
20157
|
async function deleteTemplate2(id, store, context) {
|
|
18022
20158
|
return store.deleteTemplateWithTasks(id, context);
|
|
18023
20159
|
}
|
|
18024
|
-
async function
|
|
20160
|
+
async function templateWithTasks(id, store) {
|
|
18025
20161
|
const template = await store.get("templates", id);
|
|
18026
20162
|
if (!template)
|
|
18027
20163
|
return null;
|
|
18028
|
-
|
|
18029
|
-
|
|
18030
|
-
|
|
18031
|
-
|
|
18032
|
-
|
|
18033
|
-
|
|
18034
|
-
|
|
20164
|
+
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));
|
|
20165
|
+
return { ...template, tasks };
|
|
20166
|
+
}
|
|
20167
|
+
async function updateTemplate2(id, input, store) {
|
|
20168
|
+
await store.lockTemplate(id);
|
|
20169
|
+
const current = await templateWithTasks(id, store);
|
|
20170
|
+
if (!current)
|
|
20171
|
+
return null;
|
|
20172
|
+
if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version >= 1e4)
|
|
20173
|
+
throw new Error("Template revision is invalid or exceeds the supported bound");
|
|
20174
|
+
if (input.expected_version !== undefined && input.expected_version !== current.version) {
|
|
20175
|
+
throw new ResourceConflictError("TEMPLATE_VERSION_CONFLICT", "Template changed; fetch its current version before updating");
|
|
20176
|
+
}
|
|
20177
|
+
const {
|
|
20178
|
+
id: _id,
|
|
20179
|
+
version: _version,
|
|
20180
|
+
created_at: _created,
|
|
20181
|
+
machine_id: _machine,
|
|
20182
|
+
synced_at: _synced,
|
|
20183
|
+
...snapshot
|
|
20184
|
+
} = current;
|
|
20185
|
+
await store.upsert("template_versions", {
|
|
20186
|
+
id: randomUUID4(),
|
|
20187
|
+
template_id: id,
|
|
20188
|
+
version: current.version,
|
|
20189
|
+
snapshot: JSON.stringify(snapshot),
|
|
20190
|
+
created_at: new Date().toISOString()
|
|
18035
20191
|
});
|
|
20192
|
+
const { tasks, ...template } = current;
|
|
20193
|
+
const { expected_version: _expected, ...patch } = input;
|
|
20194
|
+
const updated = await store.persistTemplateRevision({
|
|
20195
|
+
...template,
|
|
20196
|
+
...definedPatch(patch),
|
|
20197
|
+
version: current.version + 1
|
|
20198
|
+
}, current.version);
|
|
20199
|
+
return { ...updated, tasks };
|
|
20200
|
+
}
|
|
20201
|
+
async function readTemplateHistory(id, store) {
|
|
20202
|
+
await store.lockTemplate(id);
|
|
20203
|
+
const current = await store.get("templates", id);
|
|
20204
|
+
if (!current)
|
|
20205
|
+
return null;
|
|
20206
|
+
if (!Number.isSafeInteger(current.version) || current.version < 1 || current.version > 1e4)
|
|
20207
|
+
throw new Error("Template history exceeds the supported version bound");
|
|
20208
|
+
const versions = (await store.list("template_versions")).filter((row) => row.template_id === id).sort((a, b) => b.version - a.version);
|
|
20209
|
+
const seen = new Set;
|
|
20210
|
+
for (const row of versions) {
|
|
20211
|
+
if (!Number.isSafeInteger(row.version) || row.version < 1 || row.version >= current.version || seen.has(row.version))
|
|
20212
|
+
throw new Error("Template history has conflicting versions");
|
|
20213
|
+
seen.add(row.version);
|
|
20214
|
+
}
|
|
20215
|
+
const missing_versions = Array.from({ length: current.version - 1 }, (_, index) => index + 1).filter((version2) => !seen.has(version2));
|
|
20216
|
+
return {
|
|
20217
|
+
current_version: current.version,
|
|
20218
|
+
versions,
|
|
20219
|
+
selection: {
|
|
20220
|
+
schema_version: 1,
|
|
20221
|
+
template_id: id,
|
|
20222
|
+
complete: missing_versions.length === 0,
|
|
20223
|
+
missing_versions
|
|
20224
|
+
}
|
|
20225
|
+
};
|
|
20226
|
+
}
|
|
20227
|
+
async function initializeTemplates(store, context) {
|
|
20228
|
+
const existing = await store.list("templates");
|
|
20229
|
+
const records = [];
|
|
20230
|
+
for (const [definition_index, input] of builtinTemplateInputs().entries()) {
|
|
20231
|
+
const matches = existing.filter((row) => row.name === input.name);
|
|
20232
|
+
if (matches.length) {
|
|
20233
|
+
records.push({
|
|
20234
|
+
definition_index,
|
|
20235
|
+
ids: matches.map((row) => row.id).sort(),
|
|
20236
|
+
name: input.name,
|
|
20237
|
+
status: "skipped"
|
|
20238
|
+
});
|
|
20239
|
+
} else {
|
|
20240
|
+
const template = await createTemplate2(input, store, context);
|
|
20241
|
+
records.push({
|
|
20242
|
+
definition_index,
|
|
20243
|
+
ids: [template.id],
|
|
20244
|
+
name: template.name,
|
|
20245
|
+
status: "created"
|
|
20246
|
+
});
|
|
20247
|
+
}
|
|
20248
|
+
}
|
|
20249
|
+
const names = records.filter((row) => row.status === "created").map((row) => row.name);
|
|
20250
|
+
return {
|
|
20251
|
+
schema_version: 1,
|
|
20252
|
+
created: names.length,
|
|
20253
|
+
skipped: records.length - names.length,
|
|
20254
|
+
names,
|
|
20255
|
+
records
|
|
20256
|
+
};
|
|
18036
20257
|
}
|
|
18037
20258
|
async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context) {
|
|
18038
20259
|
const entry2 = {
|
|
18039
|
-
id:
|
|
20260
|
+
id: randomUUID4(),
|
|
18040
20261
|
task_id: taskId,
|
|
18041
20262
|
action,
|
|
18042
20263
|
field: field ?? null,
|
|
@@ -18050,7 +20271,7 @@ async function logTaskChange2(taskId, action, field, oldValue, newValue, agentId
|
|
|
18050
20271
|
}
|
|
18051
20272
|
async function addComment2(input, store, context) {
|
|
18052
20273
|
const comment = {
|
|
18053
|
-
id:
|
|
20274
|
+
id: randomUUID4(),
|
|
18054
20275
|
task_id: input.task_id,
|
|
18055
20276
|
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
18056
20277
|
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
@@ -18066,7 +20287,7 @@ function redactComment(comment) {
|
|
|
18066
20287
|
}
|
|
18067
20288
|
async function addPlanComment2(input, store, context) {
|
|
18068
20289
|
const comment = {
|
|
18069
|
-
id:
|
|
20290
|
+
id: randomUUID4(),
|
|
18070
20291
|
plan_id: input.plan_id,
|
|
18071
20292
|
agent_id: input.agent_id ?? context?.agentId ?? null,
|
|
18072
20293
|
session_id: input.session_id ?? context?.sessionId ?? null,
|
|
@@ -18084,6 +20305,7 @@ async function exportSnapshot(store) {
|
|
|
18084
20305
|
return {
|
|
18085
20306
|
exportedAt: new Date().toISOString(),
|
|
18086
20307
|
source: "postgres",
|
|
20308
|
+
machines: await store.list("machines"),
|
|
18087
20309
|
tasks: await store.list("tasks"),
|
|
18088
20310
|
projects: await store.list("projects"),
|
|
18089
20311
|
projectMachinePaths: await store.list("project_machine_paths"),
|
|
@@ -18096,8 +20318,27 @@ async function exportSnapshot(store) {
|
|
|
18096
20318
|
tombstones: await store.listTombstones()
|
|
18097
20319
|
};
|
|
18098
20320
|
}
|
|
18099
|
-
async function importSnapshot(snapshot, store, context) {
|
|
20321
|
+
async function importSnapshot(snapshot, store, context, machines) {
|
|
18100
20322
|
const result = { inserted: 0, updated: 0, deleted: 0, skipped: 0, errors: [] };
|
|
20323
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "atomic_project_migrations")) {
|
|
20324
|
+
result.errors.push("Migration evidence is immutable");
|
|
20325
|
+
return result;
|
|
20326
|
+
}
|
|
20327
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "machines")) {
|
|
20328
|
+
result.errors.push("Machine tombstones require explicit registry lifecycle operations");
|
|
20329
|
+
return result;
|
|
20330
|
+
}
|
|
20331
|
+
if ((snapshot.tombstones ?? []).some((row) => row.object_type === "projects")) {
|
|
20332
|
+
result.errors.push("Project tombstones require explicit reference-preserving project deletion");
|
|
20333
|
+
return result;
|
|
20334
|
+
}
|
|
20335
|
+
try {
|
|
20336
|
+
if (snapshot.machines !== undefined)
|
|
20337
|
+
validateMachines(snapshot.machines);
|
|
20338
|
+
} catch (e) {
|
|
20339
|
+
result.errors.push(e instanceof Error ? e.message : String(e));
|
|
20340
|
+
return result;
|
|
20341
|
+
}
|
|
18101
20342
|
result.errors.push(...validateSnapshotRoutingRecords(snapshot.projects, snapshot.taskLists));
|
|
18102
20343
|
if (result.errors.length > 0)
|
|
18103
20344
|
return result;
|
|
@@ -18108,18 +20349,47 @@ async function importSnapshot(snapshot, store, context) {
|
|
|
18108
20349
|
result.errors.push(...validateSnapshotRoutingDestinationConflicts(snapshot.projects, snapshot.taskLists, existingProjects, existingTaskLists));
|
|
18109
20350
|
if (result.errors.length > 0)
|
|
18110
20351
|
return result;
|
|
20352
|
+
if (new Set(snapshot.projects.map((project) => project.id)).size !== snapshot.projects.length) {
|
|
20353
|
+
result.errors.push("Snapshot contains duplicate project identities");
|
|
20354
|
+
return result;
|
|
20355
|
+
}
|
|
20356
|
+
const orderedProjects = [];
|
|
20357
|
+
const pendingProjects = new Map(snapshot.projects.map((project) => [project.id, project]));
|
|
20358
|
+
while (pendingProjects.size > 0) {
|
|
20359
|
+
const ready = [...pendingProjects.values()].filter((project) => !project.parent_id || !pendingProjects.has(project.parent_id));
|
|
20360
|
+
if (ready.length === 0) {
|
|
20361
|
+
result.errors.push("Snapshot project hierarchy contains a cycle");
|
|
20362
|
+
return result;
|
|
20363
|
+
}
|
|
20364
|
+
for (const project of ready) {
|
|
20365
|
+
orderedProjects.push(project);
|
|
20366
|
+
pendingProjects.delete(project.id);
|
|
20367
|
+
}
|
|
20368
|
+
}
|
|
18111
20369
|
const auditHistory = await preflightAuditHistoryImport2(snapshot.auditHistory, snapshot.tombstones ?? [], store);
|
|
18112
20370
|
result.errors.push(...auditHistory.errors);
|
|
18113
20371
|
if (result.errors.length > 0)
|
|
18114
20372
|
return result;
|
|
20373
|
+
if (snapshot.machines?.length) {
|
|
20374
|
+
try {
|
|
20375
|
+
if (!machines)
|
|
20376
|
+
throw new Error("Machine registry import is unavailable");
|
|
20377
|
+
const receipt = await machines.execute({ action: "import", machines: snapshot.machines });
|
|
20378
|
+
result.inserted += receipt.inserted;
|
|
20379
|
+
result.skipped += receipt.skipped;
|
|
20380
|
+
} catch (error) {
|
|
20381
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
20382
|
+
return result;
|
|
20383
|
+
}
|
|
20384
|
+
}
|
|
18115
20385
|
result.skipped += auditHistory.identical;
|
|
18116
20386
|
const entries = [
|
|
18117
|
-
...
|
|
18118
|
-
...snapshot.projects.map((row) => ["projects", row]),
|
|
20387
|
+
...orderedProjects.map((row) => ["projects", row]),
|
|
18119
20388
|
...(snapshot.projectMachinePaths ?? []).map((row) => ["project_machine_paths", row]),
|
|
18120
|
-
...snapshot.plans.map((row) => ["plans", row]),
|
|
18121
20389
|
...snapshot.agents.map((row) => ["agents", row]),
|
|
18122
20390
|
...snapshot.taskLists.map((row) => ["task_lists", row]),
|
|
20391
|
+
...snapshot.plans.map((row) => ["plans", row]),
|
|
20392
|
+
...snapshot.tasks.map((row) => ["tasks", row]),
|
|
18123
20393
|
...snapshot.templates.map((row) => ["templates", row]),
|
|
18124
20394
|
...(snapshot.templateTasks ?? []).map((row) => ["template_tasks", row])
|
|
18125
20395
|
];
|
|
@@ -18306,7 +20576,7 @@ async function retryOnTransientPostgresError(fn, attempts = 2, delayMs = 150) {
|
|
|
18306
20576
|
lastError = error;
|
|
18307
20577
|
if (!isTransientPostgresError(error) || attempt === attempts)
|
|
18308
20578
|
throw error;
|
|
18309
|
-
await new Promise((
|
|
20579
|
+
await new Promise((resolve10) => setTimeout(resolve10, delayMs * attempt));
|
|
18310
20580
|
}
|
|
18311
20581
|
}
|
|
18312
20582
|
throw lastError;
|
|
@@ -18373,7 +20643,7 @@ class TodosShadowMirror {
|
|
|
18373
20643
|
lastError: this.metrics.lastError
|
|
18374
20644
|
};
|
|
18375
20645
|
}
|
|
18376
|
-
enqueueUpsert(
|
|
20646
|
+
enqueueUpsert(key2, record, context) {
|
|
18377
20647
|
if (!record || typeof record !== "object")
|
|
18378
20648
|
return;
|
|
18379
20649
|
const payload = record;
|
|
@@ -18382,7 +20652,7 @@ class TodosShadowMirror {
|
|
|
18382
20652
|
this.metrics.enqueued += 1;
|
|
18383
20653
|
this.queue.push({
|
|
18384
20654
|
kind: "upsert",
|
|
18385
|
-
key,
|
|
20655
|
+
key: key2,
|
|
18386
20656
|
record: payload,
|
|
18387
20657
|
...context ? { context } : {},
|
|
18388
20658
|
enqueuedAt: Date.now(),
|
|
@@ -18407,8 +20677,8 @@ class TodosShadowMirror {
|
|
|
18407
20677
|
async flush() {
|
|
18408
20678
|
if (this.idle())
|
|
18409
20679
|
return;
|
|
18410
|
-
await new Promise((
|
|
18411
|
-
this.idleResolvers.push(
|
|
20680
|
+
await new Promise((resolve10) => {
|
|
20681
|
+
this.idleResolvers.push(resolve10);
|
|
18412
20682
|
this.pump();
|
|
18413
20683
|
});
|
|
18414
20684
|
}
|
|
@@ -18420,8 +20690,8 @@ class TodosShadowMirror {
|
|
|
18420
20690
|
return;
|
|
18421
20691
|
const resolvers = this.idleResolvers;
|
|
18422
20692
|
this.idleResolvers = [];
|
|
18423
|
-
for (const
|
|
18424
|
-
|
|
20693
|
+
for (const resolve10 of resolvers)
|
|
20694
|
+
resolve10();
|
|
18425
20695
|
}
|
|
18426
20696
|
pump() {
|
|
18427
20697
|
if (this.pumping)
|
|
@@ -18759,8 +21029,8 @@ function createTodosCloudQueryClient(url, options = {}) {
|
|
|
18759
21029
|
};
|
|
18760
21030
|
return client;
|
|
18761
21031
|
}
|
|
18762
|
-
function createTodosCloudQueryClientFromEnv(
|
|
18763
|
-
const url = getTodosStorageDatabaseUrl(
|
|
21032
|
+
function createTodosCloudQueryClientFromEnv(env2 = process.env, options = {}) {
|
|
21033
|
+
const url = getTodosStorageDatabaseUrl(env2);
|
|
18764
21034
|
if (!url)
|
|
18765
21035
|
return null;
|
|
18766
21036
|
return createTodosCloudQueryClient(url, options);
|
|
@@ -18900,7 +21170,7 @@ class TodosShadowOutbox {
|
|
|
18900
21170
|
const remaining = deadline - Date.now();
|
|
18901
21171
|
if (remaining <= 0)
|
|
18902
21172
|
break;
|
|
18903
|
-
await new Promise((
|
|
21173
|
+
await new Promise((resolve10) => setTimeout(resolve10, Math.min(200, remaining)));
|
|
18904
21174
|
}
|
|
18905
21175
|
}
|
|
18906
21176
|
return this.getStats();
|
|
@@ -19078,8 +21348,8 @@ var _capturedDb = null;
|
|
|
19078
21348
|
var _outbox = null;
|
|
19079
21349
|
var _cloud = null;
|
|
19080
21350
|
var _exitRegistered = false;
|
|
19081
|
-
function maybeInstallShadowCapture2(db,
|
|
19082
|
-
if (!isTodosShadowEnabled(
|
|
21351
|
+
function maybeInstallShadowCapture2(db, env2 = process.env) {
|
|
21352
|
+
if (!isTodosShadowEnabled(env2))
|
|
19083
21353
|
return false;
|
|
19084
21354
|
if (_capturedDb === db)
|
|
19085
21355
|
return true;
|
|
@@ -19087,15 +21357,15 @@ function maybeInstallShadowCapture2(db, env = process.env) {
|
|
|
19087
21357
|
_capturedDb = db;
|
|
19088
21358
|
return true;
|
|
19089
21359
|
}
|
|
19090
|
-
function getRuntimeShadowOutbox(db,
|
|
19091
|
-
if (!isTodosShadowEnabled(
|
|
19092
|
-
throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(
|
|
21360
|
+
function getRuntimeShadowOutbox(db, env2 = process.env) {
|
|
21361
|
+
if (!isTodosShadowEnabled(env2)) {
|
|
21362
|
+
throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env2)}=1 to enable it`);
|
|
19093
21363
|
}
|
|
19094
21364
|
if (_outbox && _capturedDb === db)
|
|
19095
21365
|
return _outbox;
|
|
19096
|
-
const cloud = createTodosCloudQueryClientFromEnv(
|
|
21366
|
+
const cloud = createTodosCloudQueryClientFromEnv(env2);
|
|
19097
21367
|
if (!cloud) {
|
|
19098
|
-
throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(
|
|
21368
|
+
throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env2)}`);
|
|
19099
21369
|
}
|
|
19100
21370
|
_cloud = cloud;
|
|
19101
21371
|
installShadowOutboxSchema(db);
|
|
@@ -19103,28 +21373,28 @@ function getRuntimeShadowOutbox(db, env = process.env) {
|
|
|
19103
21373
|
_outbox = new TodosShadowOutbox({ db, postgresClient: cloud });
|
|
19104
21374
|
return _outbox;
|
|
19105
21375
|
}
|
|
19106
|
-
function startRuntimeShadowDrain(db,
|
|
19107
|
-
if (!isTodosShadowEnabled(
|
|
21376
|
+
function startRuntimeShadowDrain(db, env2 = process.env) {
|
|
21377
|
+
if (!isTodosShadowEnabled(env2))
|
|
19108
21378
|
return;
|
|
19109
|
-
maybeInstallShadowCapture2(db,
|
|
21379
|
+
maybeInstallShadowCapture2(db, env2);
|
|
19110
21380
|
try {
|
|
19111
|
-
const outbox = getRuntimeShadowOutbox(db,
|
|
21381
|
+
const outbox = getRuntimeShadowOutbox(db, env2);
|
|
19112
21382
|
outbox.startLoop();
|
|
19113
|
-
registerShadowExitFlush(db,
|
|
21383
|
+
registerShadowExitFlush(db, env2);
|
|
19114
21384
|
} catch (error) {
|
|
19115
21385
|
const message = error instanceof Error ? error.message : String(error);
|
|
19116
21386
|
console.error(`[todos] shadow drain not started: ${message}`);
|
|
19117
21387
|
}
|
|
19118
21388
|
}
|
|
19119
|
-
function registerShadowExitFlush(db,
|
|
21389
|
+
function registerShadowExitFlush(db, env2 = process.env) {
|
|
19120
21390
|
if (_exitRegistered)
|
|
19121
21391
|
return;
|
|
19122
|
-
if (!isTodosShadowEnabled(
|
|
21392
|
+
if (!isTodosShadowEnabled(env2))
|
|
19123
21393
|
return;
|
|
19124
21394
|
_exitRegistered = true;
|
|
19125
21395
|
const flush = async () => {
|
|
19126
21396
|
try {
|
|
19127
|
-
const outbox = getRuntimeShadowOutbox(db,
|
|
21397
|
+
const outbox = getRuntimeShadowOutbox(db, env2);
|
|
19128
21398
|
await outbox.flush(5000);
|
|
19129
21399
|
} catch {} finally {
|
|
19130
21400
|
await closeRuntimeShadowCloud();
|
|
@@ -19282,7 +21552,7 @@ function isCommentRedactionBackfillComplete(result) {
|
|
|
19282
21552
|
return !result.dry_run && result.conflicts === 0 && result.remaining_candidates === 0;
|
|
19283
21553
|
}
|
|
19284
21554
|
// src/storage/s3-artifacts.ts
|
|
19285
|
-
import { createHash as
|
|
21555
|
+
import { createHash as createHash7, createHmac as createHmac2 } from "crypto";
|
|
19286
21556
|
function createTodosS3ArtifactStore(options) {
|
|
19287
21557
|
const requestFetch = options.fetch ?? fetch;
|
|
19288
21558
|
const now3 = options.now ?? (() => new Date);
|
|
@@ -19290,8 +21560,8 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19290
21560
|
objectKey: (relativePath) => buildS3ObjectKey(options.config, relativePath),
|
|
19291
21561
|
objectUrl: (relativePath) => buildS3ObjectUrl(options.config, buildS3ObjectKey(options.config, relativePath)),
|
|
19292
21562
|
async putObject(input) {
|
|
19293
|
-
const
|
|
19294
|
-
const url = buildS3ObjectUrl(options.config,
|
|
21563
|
+
const key2 = buildS3ObjectKey(options.config, input.key);
|
|
21564
|
+
const url = buildS3ObjectUrl(options.config, key2);
|
|
19295
21565
|
const body = normalizeBody(input.body);
|
|
19296
21566
|
const headers = {
|
|
19297
21567
|
"content-type": input.contentType ?? "application/octet-stream"
|
|
@@ -19314,7 +21584,7 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19314
21584
|
throw new Error(`S3 put failed with HTTP ${response.status}`);
|
|
19315
21585
|
return {
|
|
19316
21586
|
bucket: options.config.bucket,
|
|
19317
|
-
key,
|
|
21587
|
+
key: key2,
|
|
19318
21588
|
url: url.toString(),
|
|
19319
21589
|
etag: response.headers.get("etag") ?? undefined
|
|
19320
21590
|
};
|
|
@@ -19354,11 +21624,11 @@ function createTodosS3ArtifactStore(options) {
|
|
|
19354
21624
|
}
|
|
19355
21625
|
function buildS3ObjectKey(config, relativePath) {
|
|
19356
21626
|
const prefix = normalizeS3Prefix(config.prefix);
|
|
19357
|
-
const
|
|
19358
|
-
return `${prefix}${
|
|
21627
|
+
const key2 = normalizeS3Key(relativePath);
|
|
21628
|
+
return `${prefix}${key2}`;
|
|
19359
21629
|
}
|
|
19360
|
-
function buildS3ObjectUrl(config,
|
|
19361
|
-
const encodedKey = encodeS3Path(
|
|
21630
|
+
function buildS3ObjectUrl(config, key2) {
|
|
21631
|
+
const encodedKey = encodeS3Path(key2);
|
|
19362
21632
|
if (config.endpoint || config.forcePathStyle) {
|
|
19363
21633
|
const base = new URL(config.endpoint ?? `https://s3.${config.region ?? "us-east-1"}.amazonaws.com`);
|
|
19364
21634
|
base.pathname = `${trimTrailingSlash(base.pathname)}/${encodeURIComponent(config.bucket)}/${encodedKey}`;
|
|
@@ -19422,25 +21692,25 @@ function normalizeBody(body) {
|
|
|
19422
21692
|
throw new Error("S3 body must be a string, Uint8Array, Buffer, or ArrayBuffer");
|
|
19423
21693
|
}
|
|
19424
21694
|
function normalizeS3Prefix(value) {
|
|
19425
|
-
const
|
|
19426
|
-
if (!
|
|
21695
|
+
const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
21696
|
+
if (!normalized2 || normalized2.includes(".."))
|
|
19427
21697
|
throw new Error("Invalid S3 object key");
|
|
19428
|
-
return
|
|
21698
|
+
return normalized2.endsWith("/") ? normalized2 : `${normalized2}/`;
|
|
19429
21699
|
}
|
|
19430
21700
|
function normalizeS3Key(value) {
|
|
19431
|
-
const
|
|
19432
|
-
if (!
|
|
21701
|
+
const normalized2 = value.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
21702
|
+
if (!normalized2 || normalized2.includes("..") || normalized2.endsWith("/"))
|
|
19433
21703
|
throw new Error("Invalid S3 object key");
|
|
19434
|
-
return
|
|
21704
|
+
return normalized2;
|
|
19435
21705
|
}
|
|
19436
|
-
function encodeS3Path(
|
|
19437
|
-
return
|
|
21706
|
+
function encodeS3Path(key2) {
|
|
21707
|
+
return key2.split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/");
|
|
19438
21708
|
}
|
|
19439
21709
|
function trimTrailingSlash(value) {
|
|
19440
21710
|
return value.replace(/\/+$/, "");
|
|
19441
21711
|
}
|
|
19442
21712
|
function canonicalQuery(url) {
|
|
19443
|
-
return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([
|
|
21713
|
+
return [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key2, value]) => `${encodeURIComponent(key2)}=${encodeURIComponent(value)}`).join("&");
|
|
19444
21714
|
}
|
|
19445
21715
|
function normalizeHeaders(headers) {
|
|
19446
21716
|
const entries = Object.entries(headers).map(([name, value]) => [
|
|
@@ -19454,13 +21724,13 @@ function toAmzDate(date) {
|
|
|
19454
21724
|
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
19455
21725
|
}
|
|
19456
21726
|
function sha256Hex(value) {
|
|
19457
|
-
return
|
|
21727
|
+
return createHash7("sha256").update(value).digest("hex");
|
|
19458
21728
|
}
|
|
19459
|
-
function hmac(
|
|
19460
|
-
return createHmac2("sha256",
|
|
21729
|
+
function hmac(key2, value) {
|
|
21730
|
+
return createHmac2("sha256", key2).update(value).digest();
|
|
19461
21731
|
}
|
|
19462
|
-
function hmacHex(
|
|
19463
|
-
return createHmac2("sha256",
|
|
21732
|
+
function hmacHex(key2, value) {
|
|
21733
|
+
return createHmac2("sha256", key2).update(value).digest("hex");
|
|
19464
21734
|
}
|
|
19465
21735
|
function getSigningKey(secretAccessKey, dateStamp, region, service) {
|
|
19466
21736
|
const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
|
@@ -19494,7 +21764,7 @@ async function uploadRunArtifactsToS3(options) {
|
|
|
19494
21764
|
continue;
|
|
19495
21765
|
}
|
|
19496
21766
|
const ref = await options.store.putObject({
|
|
19497
|
-
key: content.
|
|
21767
|
+
key: remoteArtifactKey(artifact.task_id, content.sha256),
|
|
19498
21768
|
body: Buffer.from(content.base64, "base64"),
|
|
19499
21769
|
contentType: mediaType(metadata) ?? "application/octet-stream",
|
|
19500
21770
|
metadata: {
|
|
@@ -19508,7 +21778,8 @@ async function uploadRunArtifactsToS3(options) {
|
|
|
19508
21778
|
provider: "s3",
|
|
19509
21779
|
bucket: ref.bucket,
|
|
19510
21780
|
key: ref.key,
|
|
19511
|
-
relative_path: content.
|
|
21781
|
+
relative_path: remoteArtifactKey(artifact.task_id, content.sha256),
|
|
21782
|
+
local_path: content.relative_path,
|
|
19512
21783
|
url: ref.url,
|
|
19513
21784
|
sha256: content.sha256,
|
|
19514
21785
|
size_bytes: content.size_bytes,
|
|
@@ -19612,7 +21883,7 @@ async function downloadRunArtifactsFromS3(options) {
|
|
|
19612
21883
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
19613
21884
|
const report = importStoredArtifactContent({
|
|
19614
21885
|
artifact_id: artifact.id,
|
|
19615
|
-
relative_path: remote.relative_path,
|
|
21886
|
+
relative_path: remote.local_path ?? remote.relative_path,
|
|
19616
21887
|
sha256: remote.sha256,
|
|
19617
21888
|
size_bytes: remote.size_bytes,
|
|
19618
21889
|
base64: bytes.toString("base64")
|
|
@@ -19644,6 +21915,12 @@ async function downloadRunArtifactsFromS3(options) {
|
|
|
19644
21915
|
function emptyResult() {
|
|
19645
21916
|
return { uploaded: 0, downloaded: 0, skipped: 0, errors: [], artifacts: [] };
|
|
19646
21917
|
}
|
|
21918
|
+
function remoteArtifactKey(taskId, sha2562) {
|
|
21919
|
+
if (!/^[0-9a-f]{64}$/.test(sha2562)) {
|
|
21920
|
+
throw new Error("run artifact digest must be a lowercase hex sha-256; refusing to build an object key from it");
|
|
21921
|
+
}
|
|
21922
|
+
return `artifacts/${taskId}/${sha2562}`;
|
|
21923
|
+
}
|
|
19647
21924
|
function planArtifact(artifact, status, remote) {
|
|
19648
21925
|
return {
|
|
19649
21926
|
id: artifact.id,
|
|
@@ -19707,10 +21984,106 @@ function mediaType(metadata) {
|
|
|
19707
21984
|
const value = store["media_type"];
|
|
19708
21985
|
return typeof value === "string" ? value : null;
|
|
19709
21986
|
}
|
|
21987
|
+
// src/storage/run-artifact-creation-upload.ts
|
|
21988
|
+
init_database();
|
|
21989
|
+
init_artifact_store();
|
|
21990
|
+
init_config();
|
|
21991
|
+
async function uploadRunArtifactAtCreation(input) {
|
|
21992
|
+
const db = input.db ?? getDatabase();
|
|
21993
|
+
const now3 = input.now ?? (() => new Date);
|
|
21994
|
+
try {
|
|
21995
|
+
const artifact = getRunArtifactById(db, input.artifactId);
|
|
21996
|
+
if (!artifact)
|
|
21997
|
+
return { uploaded: false, reason: "artifact_not_found" };
|
|
21998
|
+
const metadata = artifact.metadata;
|
|
21999
|
+
const existing = remoteRef(metadata);
|
|
22000
|
+
if (existing)
|
|
22001
|
+
return { uploaded: true, reason: "already_remote", ref: existing };
|
|
22002
|
+
if (!input.store) {
|
|
22003
|
+
const config = loadTodosStorageConfig(input.env ?? process.env);
|
|
22004
|
+
if (!config.objectStorage)
|
|
22005
|
+
return { uploaded: false, reason: "s3_not_configured" };
|
|
22006
|
+
const credentials = creationCredentialsFromEnv(input.env ?? process.env);
|
|
22007
|
+
if (!credentials)
|
|
22008
|
+
return { uploaded: false, reason: "s3_credentials_missing" };
|
|
22009
|
+
input.store = createTodosS3ArtifactStore({ config: config.objectStorage, credentials });
|
|
22010
|
+
}
|
|
22011
|
+
const store = input.store;
|
|
22012
|
+
const content = exportStoredArtifactContent({
|
|
22013
|
+
id: artifact.id,
|
|
22014
|
+
path: artifact.path,
|
|
22015
|
+
size_bytes: artifact.size_bytes,
|
|
22016
|
+
sha256: artifact.sha256,
|
|
22017
|
+
metadata
|
|
22018
|
+
});
|
|
22019
|
+
if (!content)
|
|
22020
|
+
return { uploaded: false, reason: "no_local_content" };
|
|
22021
|
+
const relativePath = remoteArtifactKey(artifact.task_id, content.sha256);
|
|
22022
|
+
const ref = await store.putObject({
|
|
22023
|
+
key: relativePath,
|
|
22024
|
+
body: Buffer.from(content.base64, "base64"),
|
|
22025
|
+
contentType: mediaType2(metadata) ?? "application/octet-stream",
|
|
22026
|
+
metadata: {
|
|
22027
|
+
artifact_id: artifact.id,
|
|
22028
|
+
run_id: artifact.run_id,
|
|
22029
|
+
task_id: artifact.task_id,
|
|
22030
|
+
sha256: content.sha256
|
|
22031
|
+
}
|
|
22032
|
+
});
|
|
22033
|
+
const remote = {
|
|
22034
|
+
provider: "s3",
|
|
22035
|
+
bucket: ref.bucket,
|
|
22036
|
+
key: ref.key,
|
|
22037
|
+
relative_path: relativePath,
|
|
22038
|
+
local_path: content.relative_path,
|
|
22039
|
+
url: ref.url,
|
|
22040
|
+
sha256: content.sha256,
|
|
22041
|
+
size_bytes: content.size_bytes,
|
|
22042
|
+
uploaded_at: now3().toISOString()
|
|
22043
|
+
};
|
|
22044
|
+
updateArtifactMetadata(db, artifact.id, {
|
|
22045
|
+
...metadata,
|
|
22046
|
+
remote_artifact_store: remote
|
|
22047
|
+
});
|
|
22048
|
+
return { uploaded: true, reason: "uploaded", ref: remote };
|
|
22049
|
+
} catch (error) {
|
|
22050
|
+
return {
|
|
22051
|
+
uploaded: false,
|
|
22052
|
+
reason: "upload_error",
|
|
22053
|
+
error: error instanceof Error ? error.message : String(error)
|
|
22054
|
+
};
|
|
22055
|
+
}
|
|
22056
|
+
}
|
|
22057
|
+
function creationCredentialsFromEnv(env2) {
|
|
22058
|
+
const accessKeyId = readCredentialEnv(env2, "s3AccessKeyId");
|
|
22059
|
+
const secretAccessKey = readCredentialEnv(env2, "s3SecretAccessKey");
|
|
22060
|
+
if (!accessKeyId || !secretAccessKey)
|
|
22061
|
+
return null;
|
|
22062
|
+
const sessionToken = readCredentialEnv(env2, "s3SessionToken");
|
|
22063
|
+
return sessionToken ? { accessKeyId, secretAccessKey, sessionToken } : { accessKeyId, secretAccessKey };
|
|
22064
|
+
}
|
|
22065
|
+
function readCredentialEnv(env2, key2) {
|
|
22066
|
+
const value = env2[TODOS_STORAGE_ENV[key2]] ?? env2[TODOS_STORAGE_FALLBACK_ENV[key2]];
|
|
22067
|
+
const trimmed = value?.trim();
|
|
22068
|
+
return trimmed ? trimmed : undefined;
|
|
22069
|
+
}
|
|
22070
|
+
function getRunArtifactById(db, id) {
|
|
22071
|
+
const row = db.query("SELECT * FROM task_run_artifacts WHERE id = ?").get(id);
|
|
22072
|
+
return row ? { ...row, metadata: parseMetadata2(row.metadata) } : null;
|
|
22073
|
+
}
|
|
22074
|
+
function mediaType2(metadata) {
|
|
22075
|
+
const store = metadata["artifact_store"];
|
|
22076
|
+
if (!store || typeof store !== "object" || Array.isArray(store))
|
|
22077
|
+
return null;
|
|
22078
|
+
const value = store["media_type"];
|
|
22079
|
+
return typeof value === "string" ? value : null;
|
|
22080
|
+
}
|
|
19710
22081
|
export {
|
|
19711
22082
|
uploadRunArtifactsToS3,
|
|
22083
|
+
uploadRunArtifactAtCreation,
|
|
19712
22084
|
startRuntimeShadowDrain,
|
|
19713
22085
|
signAwsV4Request,
|
|
22086
|
+
remoteArtifactKey,
|
|
19714
22087
|
registerShadowExitFlush,
|
|
19715
22088
|
postgresTodosSyncSchemaSql,
|
|
19716
22089
|
postgresTodosScopedSlugUniqueIndexSql,
|