@hasna/todos 0.11.75 → 0.11.82
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/README.md +8 -0
- package/dist/cli/cloud-router.d.ts +90 -0
- package/dist/cli/cloud-router.d.ts.map +1 -0
- package/dist/cli/commands/agent-commands.d.ts.map +1 -1
- package/dist/cli/commands/plan-template-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-commands.d.ts.map +1 -1
- package/dist/cli/helpers.d.ts +22 -0
- package/dist/cli/helpers.d.ts.map +1 -1
- package/dist/cli/index.js +72556 -62360
- package/dist/contracts.js +314 -14
- package/dist/db/database.d.ts.map +1 -1
- package/dist/index.js +1160 -236
- package/dist/lib/shadow-status.d.ts +38 -0
- package/dist/lib/shadow-status.d.ts.map +1 -0
- package/dist/mcp/index.js +10378 -341
- package/dist/mcp/tools/agents.d.ts.map +1 -1
- package/dist/mcp/tools/task-adv-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-auto-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-crud.d.ts.map +1 -1
- package/dist/mcp/tools/task-project-tools.d.ts.map +1 -1
- package/dist/mcp/tools/task-workflow-tools.d.ts.map +1 -1
- package/dist/registry.js +314 -14
- package/dist/release-provenance.json +3 -3
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +135 -0
- package/dist/sdk/v1.generated.d.ts +124 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -0
- package/dist/server/cloud.d.ts +50 -0
- package/dist/server/cloud.d.ts.map +1 -0
- package/dist/server/index.js +12073 -2013
- package/dist/server/openapi.d.ts +628 -0
- package/dist/server/openapi.d.ts.map +1 -0
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/server/v1.d.ts +19 -0
- package/dist/server/v1.d.ts.map +1 -0
- package/dist/storage/cloud-client.d.ts +30 -0
- package/dist/storage/cloud-client.d.ts.map +1 -0
- package/dist/storage/config.d.ts +12 -0
- package/dist/storage/config.d.ts.map +1 -1
- package/dist/storage/factory.d.ts +2 -0
- package/dist/storage/factory.d.ts.map +1 -1
- package/dist/storage/index.d.ts +8 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/interfaces.d.ts +1 -0
- package/dist/storage/interfaces.d.ts.map +1 -1
- package/dist/storage/local-sqlite.d.ts.map +1 -1
- package/dist/storage/postgres-adapter.d.ts.map +1 -1
- package/dist/storage/postgres-sync.d.ts.map +1 -1
- package/dist/storage/shadow-outbox-schema.d.ts +21 -0
- package/dist/storage/shadow-outbox-schema.d.ts.map +1 -0
- package/dist/storage/shadow-outbox.d.ts +125 -0
- package/dist/storage/shadow-outbox.d.ts.map +1 -0
- package/dist/storage/shadow-runtime.d.ts +29 -0
- package/dist/storage/shadow-runtime.d.ts.map +1 -0
- package/dist/storage/shadow.d.ts +118 -0
- package/dist/storage/shadow.d.ts.map +1 -0
- package/dist/storage.d.ts +2 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1159 -220
- package/package.json +6 -2
- package/vendor/hasna-contracts-0.5.1.tgz +0 -0
package/dist/storage.js
CHANGED
|
@@ -39,6 +39,213 @@ var __export = (target, all) => {
|
|
|
39
39
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
40
40
|
var __require = import.meta.require;
|
|
41
41
|
|
|
42
|
+
// src/storage/config.ts
|
|
43
|
+
var exports_config = {};
|
|
44
|
+
__export(exports_config, {
|
|
45
|
+
parseStorageMode: () => parseStorageMode,
|
|
46
|
+
loadTodosStorageConfig: () => loadTodosStorageConfig,
|
|
47
|
+
loadStorageConfig: () => loadStorageConfig,
|
|
48
|
+
isTodosShadowEnabled: () => isTodosShadowEnabled,
|
|
49
|
+
isTodosRemoteStorageEnabled: () => isTodosRemoteStorageEnabled,
|
|
50
|
+
getTodosStorageShadowEnvName: () => getTodosStorageShadowEnvName,
|
|
51
|
+
getTodosStorageMode: () => getTodosStorageMode,
|
|
52
|
+
getTodosStorageEnvName: () => getTodosStorageEnvName,
|
|
53
|
+
getTodosStorageDatabaseUrl: () => getTodosStorageDatabaseUrl,
|
|
54
|
+
getTodosStorageDatabaseEnv: () => getTodosStorageDatabaseEnv,
|
|
55
|
+
getStorageMode: () => getStorageMode,
|
|
56
|
+
getStorageDatabaseUrl: () => getStorageDatabaseUrl,
|
|
57
|
+
getStorageDatabaseEnv: () => getStorageDatabaseEnv,
|
|
58
|
+
getCanonicalTodosRdsConfig: () => getCanonicalTodosRdsConfig,
|
|
59
|
+
assertTodosShadowConfig: () => assertTodosShadowConfig,
|
|
60
|
+
assertTodosRemoteStorageConfig: () => assertTodosRemoteStorageConfig,
|
|
61
|
+
TODOS_STORAGE_TABLES: () => TODOS_STORAGE_TABLES,
|
|
62
|
+
TODOS_STORAGE_FALLBACK_ENV: () => TODOS_STORAGE_FALLBACK_ENV,
|
|
63
|
+
TODOS_STORAGE_ENV: () => TODOS_STORAGE_ENV,
|
|
64
|
+
STORAGE_TABLES: () => STORAGE_TABLES,
|
|
65
|
+
CANONICAL_TODOS_RDS_RUNTIME_PATH: () => CANONICAL_TODOS_RDS_RUNTIME_PATH,
|
|
66
|
+
CANONICAL_TODOS_RDS_DATABASE: () => CANONICAL_TODOS_RDS_DATABASE,
|
|
67
|
+
CANONICAL_TODOS_RDS_CLUSTER: () => CANONICAL_TODOS_RDS_CLUSTER
|
|
68
|
+
});
|
|
69
|
+
function getCanonicalTodosRdsConfig() {
|
|
70
|
+
return {
|
|
71
|
+
cluster: CANONICAL_TODOS_RDS_CLUSTER,
|
|
72
|
+
database: CANONICAL_TODOS_RDS_DATABASE,
|
|
73
|
+
runtimeSecretPath: CANONICAL_TODOS_RDS_RUNTIME_PATH,
|
|
74
|
+
primaryEnv: TODOS_STORAGE_ENV.databaseUrl,
|
|
75
|
+
fallbackEnv: TODOS_STORAGE_FALLBACK_ENV.databaseUrl
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function loadTodosStorageConfig(env = process.env) {
|
|
79
|
+
const mode = getTodosStorageMode(env);
|
|
80
|
+
const databaseUrl = getTodosStorageDatabaseUrl(env);
|
|
81
|
+
const bucket = readStorageEnv(env, "s3Bucket").value;
|
|
82
|
+
const prefix = readStorageEnv(env, "s3Prefix").value ?? "todos/";
|
|
83
|
+
const region = readStorageEnv(env, "awsRegion").value;
|
|
84
|
+
const endpoint = readStorageEnv(env, "s3Endpoint").value;
|
|
85
|
+
const schema = readStorageEnv(env, "databaseSchema").value;
|
|
86
|
+
return {
|
|
87
|
+
service: "todos",
|
|
88
|
+
mode,
|
|
89
|
+
...databaseUrl ? {
|
|
90
|
+
database: {
|
|
91
|
+
provider: "postgres",
|
|
92
|
+
url: databaseUrl,
|
|
93
|
+
ssl: parseBoolean(readStorageEnv(env, "databaseSsl").value, true),
|
|
94
|
+
...schema ? { schema } : {}
|
|
95
|
+
}
|
|
96
|
+
} : {},
|
|
97
|
+
...bucket ? {
|
|
98
|
+
objectStorage: {
|
|
99
|
+
provider: "s3",
|
|
100
|
+
bucket,
|
|
101
|
+
prefix,
|
|
102
|
+
...region ? { region } : {},
|
|
103
|
+
...endpoint ? { endpoint } : {},
|
|
104
|
+
forcePathStyle: parseBoolean(readStorageEnv(env, "s3ForcePathStyle").value, false)
|
|
105
|
+
}
|
|
106
|
+
} : {},
|
|
107
|
+
sync: {
|
|
108
|
+
batchSize: parsePositiveInteger(readStorageEnv(env, "syncBatchSize").value, 500),
|
|
109
|
+
dryRun: parseBoolean(readStorageEnv(env, "syncDryRun").value, false)
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function loadStorageConfig(env = process.env) {
|
|
114
|
+
return loadTodosStorageConfig(env);
|
|
115
|
+
}
|
|
116
|
+
function isTodosRemoteStorageEnabled(config) {
|
|
117
|
+
return config.mode === "remote" || config.mode === "hybrid";
|
|
118
|
+
}
|
|
119
|
+
function assertTodosRemoteStorageConfig(config) {
|
|
120
|
+
if (!isTodosRemoteStorageEnabled(config))
|
|
121
|
+
return;
|
|
122
|
+
if (!config.database?.url) {
|
|
123
|
+
throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function parseStorageMode(value) {
|
|
127
|
+
const normalized = clean(value)?.toLowerCase();
|
|
128
|
+
if (!normalized)
|
|
129
|
+
return "local";
|
|
130
|
+
if (normalized === "local" || normalized === "remote" || normalized === "hybrid")
|
|
131
|
+
return normalized;
|
|
132
|
+
throw new Error(`${TODOS_STORAGE_ENV.mode} must be local, remote, or hybrid`);
|
|
133
|
+
}
|
|
134
|
+
function getTodosStorageMode(env = process.env) {
|
|
135
|
+
return parseStorageMode(readStorageEnv(env, "mode").value);
|
|
136
|
+
}
|
|
137
|
+
function getStorageMode(env = process.env) {
|
|
138
|
+
return getTodosStorageMode(env);
|
|
139
|
+
}
|
|
140
|
+
function isTodosShadowEnabled(env = process.env) {
|
|
141
|
+
return parseBoolean(readStorageEnv(env, "shadow").value, false);
|
|
142
|
+
}
|
|
143
|
+
function getTodosStorageShadowEnvName(env = process.env) {
|
|
144
|
+
return readStorageEnv(env, "shadow").name;
|
|
145
|
+
}
|
|
146
|
+
function assertTodosShadowConfig(config, env = process.env) {
|
|
147
|
+
if (!isTodosShadowEnabled(env))
|
|
148
|
+
return;
|
|
149
|
+
if (config.mode !== "local") {
|
|
150
|
+
throw new Error(`${readStorageEnv(env, "shadow").name} shadow mirror requires ${TODOS_STORAGE_ENV.mode}=local (got ${config.mode})`);
|
|
151
|
+
}
|
|
152
|
+
if (!config.database?.url) {
|
|
153
|
+
throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${readStorageEnv(env, "shadow").name} is enabled`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function getTodosStorageDatabaseEnv(env = process.env) {
|
|
157
|
+
return readStorageEnv(env, "databaseUrl").name;
|
|
158
|
+
}
|
|
159
|
+
function getStorageDatabaseEnv(env = process.env) {
|
|
160
|
+
return getTodosStorageDatabaseEnv(env);
|
|
161
|
+
}
|
|
162
|
+
function getTodosStorageDatabaseUrl(env = process.env) {
|
|
163
|
+
return readStorageEnv(env, "databaseUrl").value;
|
|
164
|
+
}
|
|
165
|
+
function getStorageDatabaseUrl(env = process.env) {
|
|
166
|
+
return getTodosStorageDatabaseUrl(env);
|
|
167
|
+
}
|
|
168
|
+
function getTodosStorageEnvName(env, key) {
|
|
169
|
+
return readStorageEnv(env, key).name;
|
|
170
|
+
}
|
|
171
|
+
function readStorageEnv(env, key) {
|
|
172
|
+
const primaryName = TODOS_STORAGE_ENV[key];
|
|
173
|
+
const primaryValue = clean(env[primaryName]);
|
|
174
|
+
if (primaryValue !== undefined)
|
|
175
|
+
return { name: primaryName, value: primaryValue };
|
|
176
|
+
const fallbackName = TODOS_STORAGE_FALLBACK_ENV[key];
|
|
177
|
+
const fallbackValue = clean(env[fallbackName]);
|
|
178
|
+
if (fallbackValue !== undefined)
|
|
179
|
+
return { name: fallbackName, value: fallbackValue };
|
|
180
|
+
return { name: primaryName };
|
|
181
|
+
}
|
|
182
|
+
function clean(value) {
|
|
183
|
+
const trimmed = value?.trim();
|
|
184
|
+
return trimmed ? trimmed : undefined;
|
|
185
|
+
}
|
|
186
|
+
function parseBoolean(value, fallback) {
|
|
187
|
+
const normalized = clean(value)?.toLowerCase();
|
|
188
|
+
if (!normalized)
|
|
189
|
+
return fallback;
|
|
190
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
191
|
+
return true;
|
|
192
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
193
|
+
return false;
|
|
194
|
+
throw new Error(`Expected boolean env value, got ${value}`);
|
|
195
|
+
}
|
|
196
|
+
function parsePositiveInteger(value, fallback) {
|
|
197
|
+
const normalized = clean(value);
|
|
198
|
+
if (!normalized)
|
|
199
|
+
return fallback;
|
|
200
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
201
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
202
|
+
throw new Error(`${TODOS_STORAGE_ENV.syncBatchSize} must be a positive integer`);
|
|
203
|
+
}
|
|
204
|
+
return parsed;
|
|
205
|
+
}
|
|
206
|
+
var TODOS_STORAGE_TABLES, STORAGE_TABLES, TODOS_STORAGE_ENV, TODOS_STORAGE_FALLBACK_ENV, CANONICAL_TODOS_RDS_CLUSTER = "hasna-xyz-infra-apps-prod-postgres", CANONICAL_TODOS_RDS_DATABASE = "todos", CANONICAL_TODOS_RDS_RUNTIME_PATH = "hasna/xyz/opensource/todos/prod/rds";
|
|
207
|
+
var init_config = __esm(() => {
|
|
208
|
+
TODOS_STORAGE_TABLES = [
|
|
209
|
+
"todos_sync_records",
|
|
210
|
+
"todos_sync_cursors"
|
|
211
|
+
];
|
|
212
|
+
STORAGE_TABLES = TODOS_STORAGE_TABLES;
|
|
213
|
+
TODOS_STORAGE_ENV = {
|
|
214
|
+
mode: "HASNA_TODOS_STORAGE_MODE",
|
|
215
|
+
shadow: "HASNA_TODOS_SHADOW",
|
|
216
|
+
databaseUrl: "HASNA_TODOS_DATABASE_URL",
|
|
217
|
+
databaseSsl: "HASNA_TODOS_DATABASE_SSL",
|
|
218
|
+
databaseSchema: "HASNA_TODOS_DATABASE_SCHEMA",
|
|
219
|
+
s3Bucket: "HASNA_TODOS_S3_BUCKET",
|
|
220
|
+
s3Prefix: "HASNA_TODOS_S3_PREFIX",
|
|
221
|
+
awsRegion: "HASNA_TODOS_AWS_REGION",
|
|
222
|
+
s3Endpoint: "HASNA_TODOS_S3_ENDPOINT",
|
|
223
|
+
s3ForcePathStyle: "HASNA_TODOS_S3_FORCE_PATH_STYLE",
|
|
224
|
+
s3AccessKeyId: "HASNA_TODOS_S3_ACCESS_KEY_ID",
|
|
225
|
+
s3SecretAccessKey: "HASNA_TODOS_S3_SECRET_ACCESS_KEY",
|
|
226
|
+
s3SessionToken: "HASNA_TODOS_S3_SESSION_TOKEN",
|
|
227
|
+
syncBatchSize: "HASNA_TODOS_SYNC_BATCH_SIZE",
|
|
228
|
+
syncDryRun: "HASNA_TODOS_SYNC_DRY_RUN"
|
|
229
|
+
};
|
|
230
|
+
TODOS_STORAGE_FALLBACK_ENV = {
|
|
231
|
+
mode: "TODOS_STORAGE_MODE",
|
|
232
|
+
shadow: "TODOS_SHADOW",
|
|
233
|
+
databaseUrl: "TODOS_DATABASE_URL",
|
|
234
|
+
databaseSsl: "TODOS_DATABASE_SSL",
|
|
235
|
+
databaseSchema: "TODOS_DATABASE_SCHEMA",
|
|
236
|
+
s3Bucket: "TODOS_S3_BUCKET",
|
|
237
|
+
s3Prefix: "TODOS_S3_PREFIX",
|
|
238
|
+
awsRegion: "TODOS_AWS_REGION",
|
|
239
|
+
s3Endpoint: "TODOS_S3_ENDPOINT",
|
|
240
|
+
s3ForcePathStyle: "TODOS_S3_FORCE_PATH_STYLE",
|
|
241
|
+
s3AccessKeyId: "TODOS_S3_ACCESS_KEY_ID",
|
|
242
|
+
s3SecretAccessKey: "TODOS_S3_SECRET_ACCESS_KEY",
|
|
243
|
+
s3SessionToken: "TODOS_S3_SESSION_TOKEN",
|
|
244
|
+
syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
|
|
245
|
+
syncDryRun: "TODOS_SYNC_DRY_RUN"
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
42
249
|
// src/types/index.ts
|
|
43
250
|
var TASK_STATUSES, TASK_PRIORITIES, PLAN_STATUSES, VersionConflictError, TaskNotFoundError, ProjectNotFoundError, PlanNotFoundError, LockError, AgentNotFoundError, TaskListNotFoundError, DependencyCycleError, CompletionGuardError, DISPATCH_STATUSES, DispatchNotFoundError;
|
|
44
251
|
var init_types = __esm(() => {
|
|
@@ -2773,6 +2980,87 @@ var init_machines = __esm(() => {
|
|
|
2773
2980
|
];
|
|
2774
2981
|
});
|
|
2775
2982
|
|
|
2983
|
+
// src/storage/shadow-outbox-schema.ts
|
|
2984
|
+
var exports_shadow_outbox_schema = {};
|
|
2985
|
+
__export(exports_shadow_outbox_schema, {
|
|
2986
|
+
installShadowOutboxSchema: () => installShadowOutboxSchema,
|
|
2987
|
+
SHADOW_TRIGGER_TABLES: () => SHADOW_TRIGGER_TABLES
|
|
2988
|
+
});
|
|
2989
|
+
function installShadowOutboxSchema(db) {
|
|
2990
|
+
db.run(`CREATE TABLE IF NOT EXISTS shadow_outbox (
|
|
2991
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2992
|
+
object_type TEXT NOT NULL,
|
|
2993
|
+
object_id TEXT NOT NULL,
|
|
2994
|
+
op TEXT NOT NULL,
|
|
2995
|
+
enqueued_at INTEGER NOT NULL,
|
|
2996
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
2997
|
+
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
|
2998
|
+
last_error TEXT,
|
|
2999
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
3000
|
+
revision INTEGER NOT NULL DEFAULT 0
|
|
3001
|
+
)`);
|
|
3002
|
+
ensureColumn(db, "shadow_outbox", "revision", "INTEGER NOT NULL DEFAULT 0");
|
|
3003
|
+
db.run(`CREATE UNIQUE INDEX IF NOT EXISTS shadow_outbox_pending_uq
|
|
3004
|
+
ON shadow_outbox(object_type, object_id) WHERE status='pending'`);
|
|
3005
|
+
db.run(`CREATE INDEX IF NOT EXISTS shadow_outbox_ready_idx
|
|
3006
|
+
ON shadow_outbox(status, next_attempt_at)`);
|
|
3007
|
+
for (const { table, objectType, deletes } of SHADOW_TRIGGER_TABLES) {
|
|
3008
|
+
const exists = db.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`).get(table);
|
|
3009
|
+
if (!exists)
|
|
3010
|
+
continue;
|
|
3011
|
+
const insertTrigger = `shadow_ob_${table}_ai`;
|
|
3012
|
+
const updateTrigger = `shadow_ob_${table}_au`;
|
|
3013
|
+
db.run(`DROP TRIGGER IF EXISTS ${insertTrigger}`);
|
|
3014
|
+
db.run(`DROP TRIGGER IF EXISTS ${updateTrigger}`);
|
|
3015
|
+
db.run(upsertTriggerSql(insertTrigger, table, objectType, "INSERT", "NEW"));
|
|
3016
|
+
db.run(upsertTriggerSql(updateTrigger, table, objectType, "UPDATE", "NEW"));
|
|
3017
|
+
if (deletes) {
|
|
3018
|
+
const deleteTrigger = `shadow_ob_${table}_ad`;
|
|
3019
|
+
db.run(`DROP TRIGGER IF EXISTS ${deleteTrigger}`);
|
|
3020
|
+
db.run(deleteTriggerSql(deleteTrigger, table, objectType));
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
function ensureColumn(db, table, column, definition) {
|
|
3025
|
+
const rows = db.query(`PRAGMA table_info(${table})`).all();
|
|
3026
|
+
if (rows.some((row) => row.name === column))
|
|
3027
|
+
return;
|
|
3028
|
+
db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
3029
|
+
}
|
|
3030
|
+
function upsertTriggerSql(name, table, objectType, event, row) {
|
|
3031
|
+
return `CREATE TRIGGER IF NOT EXISTS ${name}
|
|
3032
|
+
AFTER ${event} ON ${table}
|
|
3033
|
+
BEGIN
|
|
3034
|
+
INSERT INTO shadow_outbox(object_type, object_id, op, enqueued_at, attempts, next_attempt_at, last_error, status, revision)
|
|
3035
|
+
VALUES('${objectType}', ${row}.id, 'upsert', CAST(strftime('%s','now') AS INTEGER) * 1000, 0, 0, NULL, 'pending', 0)
|
|
3036
|
+
ON CONFLICT(object_type, object_id) WHERE status='pending'
|
|
3037
|
+
DO UPDATE SET op='upsert', enqueued_at=excluded.enqueued_at, attempts=0, next_attempt_at=0, last_error=NULL, revision=shadow_outbox.revision + 1;
|
|
3038
|
+
END`;
|
|
3039
|
+
}
|
|
3040
|
+
function deleteTriggerSql(name, table, objectType) {
|
|
3041
|
+
return `CREATE TRIGGER IF NOT EXISTS ${name}
|
|
3042
|
+
AFTER DELETE ON ${table}
|
|
3043
|
+
BEGIN
|
|
3044
|
+
INSERT INTO shadow_outbox(object_type, object_id, op, enqueued_at, attempts, next_attempt_at, last_error, status, revision)
|
|
3045
|
+
VALUES('${objectType}', OLD.id, 'delete', CAST(strftime('%s','now') AS INTEGER) * 1000, 0, 0, NULL, 'pending', 0)
|
|
3046
|
+
ON CONFLICT(object_type, object_id) WHERE status='pending'
|
|
3047
|
+
DO UPDATE SET op='delete', enqueued_at=excluded.enqueued_at, attempts=0, next_attempt_at=0, last_error=NULL, revision=shadow_outbox.revision + 1;
|
|
3048
|
+
END`;
|
|
3049
|
+
}
|
|
3050
|
+
var SHADOW_TRIGGER_TABLES;
|
|
3051
|
+
var init_shadow_outbox_schema = __esm(() => {
|
|
3052
|
+
SHADOW_TRIGGER_TABLES = [
|
|
3053
|
+
{ table: "tasks", objectType: "tasks", deletes: true },
|
|
3054
|
+
{ table: "projects", objectType: "projects", deletes: true },
|
|
3055
|
+
{ table: "project_machine_paths", objectType: "project_machine_paths", deletes: true },
|
|
3056
|
+
{ table: "plans", objectType: "plans", deletes: true },
|
|
3057
|
+
{ table: "agents", objectType: "agents", deletes: false },
|
|
3058
|
+
{ table: "task_lists", objectType: "task_lists", deletes: true },
|
|
3059
|
+
{ table: "task_templates", objectType: "templates", deletes: true },
|
|
3060
|
+
{ table: "task_history", objectType: "audit_history", deletes: false }
|
|
3061
|
+
];
|
|
3062
|
+
});
|
|
3063
|
+
|
|
2776
3064
|
// src/db/database.ts
|
|
2777
3065
|
var exports_database = {};
|
|
2778
3066
|
__export(exports_database, {
|
|
@@ -2863,8 +3151,20 @@ function openDatabase(path) {
|
|
|
2863
3151
|
runMigrations(db);
|
|
2864
3152
|
backfillTaskTags(db);
|
|
2865
3153
|
backfillMachineId(db);
|
|
3154
|
+
maybeInstallShadowCapture(db);
|
|
2866
3155
|
return db;
|
|
2867
3156
|
}
|
|
3157
|
+
function maybeInstallShadowCapture(db) {
|
|
3158
|
+
try {
|
|
3159
|
+
const { isTodosShadowEnabled: isTodosShadowEnabled2 } = (init_config(), __toCommonJS(exports_config));
|
|
3160
|
+
if (!isTodosShadowEnabled2())
|
|
3161
|
+
return;
|
|
3162
|
+
const { installShadowOutboxSchema: installShadowOutboxSchema2 } = (init_shadow_outbox_schema(), __toCommonJS(exports_shadow_outbox_schema));
|
|
3163
|
+
installShadowOutboxSchema2(db);
|
|
3164
|
+
} catch (error) {
|
|
3165
|
+
console.error(`[todos] shadow capture install failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
2868
3168
|
function getDatabase(dbPath) {
|
|
2869
3169
|
const path = dbPath || getDbPath();
|
|
2870
3170
|
if (_db && _dbPath === path)
|
|
@@ -3111,7 +3411,7 @@ function getCompletionGuardConfig(projectPath) {
|
|
|
3111
3411
|
return global;
|
|
3112
3412
|
}
|
|
3113
3413
|
var cached = null, GUARD_DEFAULTS;
|
|
3114
|
-
var
|
|
3414
|
+
var init_config2 = __esm(() => {
|
|
3115
3415
|
init_sync_utils();
|
|
3116
3416
|
GUARD_DEFAULTS = {
|
|
3117
3417
|
enabled: false,
|
|
@@ -3482,7 +3782,7 @@ function checkCompletionGuard(task, agentId, db, configOverride) {
|
|
|
3482
3782
|
}
|
|
3483
3783
|
var init_completion_guard = __esm(() => {
|
|
3484
3784
|
init_types();
|
|
3485
|
-
|
|
3785
|
+
init_config2();
|
|
3486
3786
|
init_projects();
|
|
3487
3787
|
});
|
|
3488
3788
|
|
|
@@ -3490,8 +3790,8 @@ var init_completion_guard = __esm(() => {
|
|
|
3490
3790
|
import { tmpdir } from "os";
|
|
3491
3791
|
import { resolve as resolve3, sep } from "path";
|
|
3492
3792
|
function envFlag(name) {
|
|
3493
|
-
const value = process.env[name];
|
|
3494
|
-
return value === "1" || value === "true" || value === "yes";
|
|
3793
|
+
const value = process.env[name]?.trim().toLowerCase();
|
|
3794
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
3495
3795
|
}
|
|
3496
3796
|
function isUnder(parent, child) {
|
|
3497
3797
|
const normalizedParent = resolve3(parent);
|
|
@@ -3517,7 +3817,7 @@ function usesIsolatedTodosHome() {
|
|
|
3517
3817
|
function shouldEmitSharedTaskEvents(dbPath) {
|
|
3518
3818
|
if (envFlag("TODOS_DISABLE_SHARED_EVENTS"))
|
|
3519
3819
|
return false;
|
|
3520
|
-
if (envFlag("TODOS_ALLOW_EPHEMERAL_SHARED_EVENTS"))
|
|
3820
|
+
if (envFlag("TODOS_ALLOW_EPHEMERAL_SHARED_EVENTS") || envFlag("HASNA_TODOS_ALLOW_GLOBAL_EVENTS_FROM_TEMP_DB"))
|
|
3521
3821
|
return true;
|
|
3522
3822
|
if (!isEphemeralTodosDatabase(dbPath))
|
|
3523
3823
|
return true;
|
|
@@ -3619,7 +3919,7 @@ function upsertSecretSafetyConfig(input) {
|
|
|
3619
3919
|
}
|
|
3620
3920
|
var DEFAULT_SECRET_PATTERNS, DEFAULT_SECRET_KEY_PATTERN, NON_SECRET_USAGE_KEYS;
|
|
3621
3921
|
var init_redaction = __esm(() => {
|
|
3622
|
-
|
|
3922
|
+
init_config2();
|
|
3623
3923
|
DEFAULT_SECRET_PATTERNS = [
|
|
3624
3924
|
{ name: "aws-access-key", regex: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/g, replacement: "[REDACTED_AWS_KEY]" },
|
|
3625
3925
|
{ name: "private-key", regex: /-----BEGIN (?:RSA |EC |OPENSSH |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |)PRIVATE KEY-----/g, replacement: "[REDACTED_PRIVATE_KEY]" },
|
|
@@ -3776,7 +4076,7 @@ function checkWorkspacePermission(input = {}) {
|
|
|
3776
4076
|
}
|
|
3777
4077
|
var DEFAULT_DENYLIST, DEFAULT_ENV_REDACTIONS, PRESET_DEFAULTS;
|
|
3778
4078
|
var init_workspace_trust = __esm(() => {
|
|
3779
|
-
|
|
4079
|
+
init_config2();
|
|
3780
4080
|
DEFAULT_DENYLIST = ["rm -rf", "mkfs", "dd if=", "curl | sh", "wget | sh"];
|
|
3781
4081
|
DEFAULT_ENV_REDACTIONS = ["API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH"];
|
|
3782
4082
|
PRESET_DEFAULTS = {
|
|
@@ -4010,7 +4310,7 @@ function explainRunnerSandbox(input = {}) {
|
|
|
4010
4310
|
}
|
|
4011
4311
|
var DEFAULT_COMMAND_DENYLIST, DEFAULT_ENV_REDACTIONS2;
|
|
4012
4312
|
var init_runner_sandbox = __esm(() => {
|
|
4013
|
-
|
|
4313
|
+
init_config2();
|
|
4014
4314
|
init_workspace_trust();
|
|
4015
4315
|
DEFAULT_COMMAND_DENYLIST = ["rm -rf", "mkfs", "dd if=", "curl | sh", "wget | sh"];
|
|
4016
4316
|
DEFAULT_ENV_REDACTIONS2 = ["API_KEY", "TOKEN", "SECRET", "PASSWORD", "AUTH"];
|
|
@@ -4246,7 +4546,7 @@ var LOCAL_EVENT_TYPES, VALID_TARGETS;
|
|
|
4246
4546
|
var init_event_hooks = __esm(() => {
|
|
4247
4547
|
init_redaction();
|
|
4248
4548
|
init_runner_sandbox();
|
|
4249
|
-
|
|
4549
|
+
init_config2();
|
|
4250
4550
|
init_event_emission_safety();
|
|
4251
4551
|
LOCAL_EVENT_TYPES = [
|
|
4252
4552
|
"task.created",
|
|
@@ -10224,168 +10524,9 @@ var init_tasks = __esm(() => {
|
|
|
10224
10524
|
init_calendar();
|
|
10225
10525
|
});
|
|
10226
10526
|
|
|
10227
|
-
// src/storage/
|
|
10228
|
-
|
|
10229
|
-
|
|
10230
|
-
"todos_sync_cursors"
|
|
10231
|
-
];
|
|
10232
|
-
var STORAGE_TABLES = TODOS_STORAGE_TABLES;
|
|
10233
|
-
var TODOS_STORAGE_ENV = {
|
|
10234
|
-
mode: "HASNA_TODOS_STORAGE_MODE",
|
|
10235
|
-
databaseUrl: "HASNA_TODOS_DATABASE_URL",
|
|
10236
|
-
databaseSsl: "HASNA_TODOS_DATABASE_SSL",
|
|
10237
|
-
databaseSchema: "HASNA_TODOS_DATABASE_SCHEMA",
|
|
10238
|
-
s3Bucket: "HASNA_TODOS_S3_BUCKET",
|
|
10239
|
-
s3Prefix: "HASNA_TODOS_S3_PREFIX",
|
|
10240
|
-
awsRegion: "HASNA_TODOS_AWS_REGION",
|
|
10241
|
-
s3Endpoint: "HASNA_TODOS_S3_ENDPOINT",
|
|
10242
|
-
s3ForcePathStyle: "HASNA_TODOS_S3_FORCE_PATH_STYLE",
|
|
10243
|
-
s3AccessKeyId: "HASNA_TODOS_S3_ACCESS_KEY_ID",
|
|
10244
|
-
s3SecretAccessKey: "HASNA_TODOS_S3_SECRET_ACCESS_KEY",
|
|
10245
|
-
s3SessionToken: "HASNA_TODOS_S3_SESSION_TOKEN",
|
|
10246
|
-
syncBatchSize: "HASNA_TODOS_SYNC_BATCH_SIZE",
|
|
10247
|
-
syncDryRun: "HASNA_TODOS_SYNC_DRY_RUN"
|
|
10248
|
-
};
|
|
10249
|
-
var TODOS_STORAGE_FALLBACK_ENV = {
|
|
10250
|
-
mode: "TODOS_STORAGE_MODE",
|
|
10251
|
-
databaseUrl: "TODOS_DATABASE_URL",
|
|
10252
|
-
databaseSsl: "TODOS_DATABASE_SSL",
|
|
10253
|
-
databaseSchema: "TODOS_DATABASE_SCHEMA",
|
|
10254
|
-
s3Bucket: "TODOS_S3_BUCKET",
|
|
10255
|
-
s3Prefix: "TODOS_S3_PREFIX",
|
|
10256
|
-
awsRegion: "TODOS_AWS_REGION",
|
|
10257
|
-
s3Endpoint: "TODOS_S3_ENDPOINT",
|
|
10258
|
-
s3ForcePathStyle: "TODOS_S3_FORCE_PATH_STYLE",
|
|
10259
|
-
s3AccessKeyId: "TODOS_S3_ACCESS_KEY_ID",
|
|
10260
|
-
s3SecretAccessKey: "TODOS_S3_SECRET_ACCESS_KEY",
|
|
10261
|
-
s3SessionToken: "TODOS_S3_SESSION_TOKEN",
|
|
10262
|
-
syncBatchSize: "TODOS_SYNC_BATCH_SIZE",
|
|
10263
|
-
syncDryRun: "TODOS_SYNC_DRY_RUN"
|
|
10264
|
-
};
|
|
10265
|
-
var CANONICAL_TODOS_RDS_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
|
|
10266
|
-
var CANONICAL_TODOS_RDS_DATABASE = "todos";
|
|
10267
|
-
var CANONICAL_TODOS_RDS_RUNTIME_PATH = "hasna/xyz/opensource/todos/prod/rds";
|
|
10268
|
-
function getCanonicalTodosRdsConfig() {
|
|
10269
|
-
return {
|
|
10270
|
-
cluster: CANONICAL_TODOS_RDS_CLUSTER,
|
|
10271
|
-
database: CANONICAL_TODOS_RDS_DATABASE,
|
|
10272
|
-
runtimeSecretPath: CANONICAL_TODOS_RDS_RUNTIME_PATH,
|
|
10273
|
-
primaryEnv: TODOS_STORAGE_ENV.databaseUrl,
|
|
10274
|
-
fallbackEnv: TODOS_STORAGE_FALLBACK_ENV.databaseUrl
|
|
10275
|
-
};
|
|
10276
|
-
}
|
|
10277
|
-
function loadTodosStorageConfig(env = process.env) {
|
|
10278
|
-
const mode = getTodosStorageMode(env);
|
|
10279
|
-
const databaseUrl = getTodosStorageDatabaseUrl(env);
|
|
10280
|
-
const bucket = readStorageEnv(env, "s3Bucket").value;
|
|
10281
|
-
const prefix = readStorageEnv(env, "s3Prefix").value ?? "todos/";
|
|
10282
|
-
const region = readStorageEnv(env, "awsRegion").value;
|
|
10283
|
-
const endpoint = readStorageEnv(env, "s3Endpoint").value;
|
|
10284
|
-
const schema = readStorageEnv(env, "databaseSchema").value;
|
|
10285
|
-
return {
|
|
10286
|
-
service: "todos",
|
|
10287
|
-
mode,
|
|
10288
|
-
...databaseUrl ? {
|
|
10289
|
-
database: {
|
|
10290
|
-
provider: "postgres",
|
|
10291
|
-
url: databaseUrl,
|
|
10292
|
-
ssl: parseBoolean(readStorageEnv(env, "databaseSsl").value, true),
|
|
10293
|
-
...schema ? { schema } : {}
|
|
10294
|
-
}
|
|
10295
|
-
} : {},
|
|
10296
|
-
...bucket ? {
|
|
10297
|
-
objectStorage: {
|
|
10298
|
-
provider: "s3",
|
|
10299
|
-
bucket,
|
|
10300
|
-
prefix,
|
|
10301
|
-
...region ? { region } : {},
|
|
10302
|
-
...endpoint ? { endpoint } : {},
|
|
10303
|
-
forcePathStyle: parseBoolean(readStorageEnv(env, "s3ForcePathStyle").value, false)
|
|
10304
|
-
}
|
|
10305
|
-
} : {},
|
|
10306
|
-
sync: {
|
|
10307
|
-
batchSize: parsePositiveInteger(readStorageEnv(env, "syncBatchSize").value, 500),
|
|
10308
|
-
dryRun: parseBoolean(readStorageEnv(env, "syncDryRun").value, false)
|
|
10309
|
-
}
|
|
10310
|
-
};
|
|
10311
|
-
}
|
|
10312
|
-
function loadStorageConfig(env = process.env) {
|
|
10313
|
-
return loadTodosStorageConfig(env);
|
|
10314
|
-
}
|
|
10315
|
-
function isTodosRemoteStorageEnabled(config) {
|
|
10316
|
-
return config.mode === "remote" || config.mode === "hybrid";
|
|
10317
|
-
}
|
|
10318
|
-
function assertTodosRemoteStorageConfig(config) {
|
|
10319
|
-
if (!isTodosRemoteStorageEnabled(config))
|
|
10320
|
-
return;
|
|
10321
|
-
if (!config.database?.url) {
|
|
10322
|
-
throw new Error(`${TODOS_STORAGE_ENV.databaseUrl} is required when ${TODOS_STORAGE_ENV.mode}=${config.mode}`);
|
|
10323
|
-
}
|
|
10324
|
-
}
|
|
10325
|
-
function parseStorageMode(value) {
|
|
10326
|
-
const normalized = clean(value)?.toLowerCase();
|
|
10327
|
-
if (!normalized)
|
|
10328
|
-
return "local";
|
|
10329
|
-
if (normalized === "local" || normalized === "remote" || normalized === "hybrid")
|
|
10330
|
-
return normalized;
|
|
10331
|
-
throw new Error(`${TODOS_STORAGE_ENV.mode} must be local, remote, or hybrid`);
|
|
10332
|
-
}
|
|
10333
|
-
function getTodosStorageMode(env = process.env) {
|
|
10334
|
-
return parseStorageMode(readStorageEnv(env, "mode").value);
|
|
10335
|
-
}
|
|
10336
|
-
function getStorageMode(env = process.env) {
|
|
10337
|
-
return getTodosStorageMode(env);
|
|
10338
|
-
}
|
|
10339
|
-
function getTodosStorageDatabaseEnv(env = process.env) {
|
|
10340
|
-
return readStorageEnv(env, "databaseUrl").name;
|
|
10341
|
-
}
|
|
10342
|
-
function getStorageDatabaseEnv(env = process.env) {
|
|
10343
|
-
return getTodosStorageDatabaseEnv(env);
|
|
10344
|
-
}
|
|
10345
|
-
function getTodosStorageDatabaseUrl(env = process.env) {
|
|
10346
|
-
return readStorageEnv(env, "databaseUrl").value;
|
|
10347
|
-
}
|
|
10348
|
-
function getStorageDatabaseUrl(env = process.env) {
|
|
10349
|
-
return getTodosStorageDatabaseUrl(env);
|
|
10350
|
-
}
|
|
10351
|
-
function getTodosStorageEnvName(env, key) {
|
|
10352
|
-
return readStorageEnv(env, key).name;
|
|
10353
|
-
}
|
|
10354
|
-
function readStorageEnv(env, key) {
|
|
10355
|
-
const primaryName = TODOS_STORAGE_ENV[key];
|
|
10356
|
-
const primaryValue = clean(env[primaryName]);
|
|
10357
|
-
if (primaryValue !== undefined)
|
|
10358
|
-
return { name: primaryName, value: primaryValue };
|
|
10359
|
-
const fallbackName = TODOS_STORAGE_FALLBACK_ENV[key];
|
|
10360
|
-
const fallbackValue = clean(env[fallbackName]);
|
|
10361
|
-
if (fallbackValue !== undefined)
|
|
10362
|
-
return { name: fallbackName, value: fallbackValue };
|
|
10363
|
-
return { name: primaryName };
|
|
10364
|
-
}
|
|
10365
|
-
function clean(value) {
|
|
10366
|
-
const trimmed = value?.trim();
|
|
10367
|
-
return trimmed ? trimmed : undefined;
|
|
10368
|
-
}
|
|
10369
|
-
function parseBoolean(value, fallback) {
|
|
10370
|
-
const normalized = clean(value)?.toLowerCase();
|
|
10371
|
-
if (!normalized)
|
|
10372
|
-
return fallback;
|
|
10373
|
-
if (["1", "true", "yes", "on"].includes(normalized))
|
|
10374
|
-
return true;
|
|
10375
|
-
if (["0", "false", "no", "off"].includes(normalized))
|
|
10376
|
-
return false;
|
|
10377
|
-
throw new Error(`Expected boolean env value, got ${value}`);
|
|
10378
|
-
}
|
|
10379
|
-
function parsePositiveInteger(value, fallback) {
|
|
10380
|
-
const normalized = clean(value);
|
|
10381
|
-
if (!normalized)
|
|
10382
|
-
return fallback;
|
|
10383
|
-
const parsed = Number.parseInt(normalized, 10);
|
|
10384
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
10385
|
-
throw new Error(`${TODOS_STORAGE_ENV.syncBatchSize} must be a positive integer`);
|
|
10386
|
-
}
|
|
10387
|
-
return parsed;
|
|
10388
|
-
}
|
|
10527
|
+
// src/storage/index.ts
|
|
10528
|
+
init_config();
|
|
10529
|
+
|
|
10389
10530
|
// src/storage/local-sqlite.ts
|
|
10390
10531
|
init_tasks();
|
|
10391
10532
|
init_projects();
|
|
@@ -11461,6 +11602,7 @@ function createLocalSqliteTodosStorageAdapter(options = {}) {
|
|
|
11461
11602
|
audit: {
|
|
11462
11603
|
logTaskChange: (taskId, action, field, oldValue, newValue, agentId) => logTaskChange(taskId, action, field, oldValue, newValue, agentId, database()),
|
|
11463
11604
|
addComment: (input) => addComment(input, database()),
|
|
11605
|
+
getComments: (taskId) => listComments(taskId, database()),
|
|
11464
11606
|
getTaskHistory: (taskId) => getTaskHistory(taskId, database()),
|
|
11465
11607
|
getRecentActivity: (limit) => getRecentActivity(limit, database())
|
|
11466
11608
|
},
|
|
@@ -11496,6 +11638,9 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
|
|
|
11496
11638
|
PRIMARY KEY (service, object_type, object_id)
|
|
11497
11639
|
)`,
|
|
11498
11640
|
`CREATE INDEX IF NOT EXISTS ${tableName}_updated_idx ON ${tableName} (service, updated_at)`,
|
|
11641
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_status_idx ON ${tableName} ((payload->>'status')) WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
11642
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_task_project_idx ON ${tableName} ((payload->>'project_id')) WHERE object_type = 'tasks' AND deleted_at IS NULL`,
|
|
11643
|
+
`CREATE INDEX IF NOT EXISTS ${tableName}_payload_gin ON ${tableName} USING gin (payload jsonb_path_ops)`,
|
|
11499
11644
|
`CREATE TABLE IF NOT EXISTS ${cursorTableName} (
|
|
11500
11645
|
service text NOT NULL,
|
|
11501
11646
|
cursor_name text NOT NULL,
|
|
@@ -11546,7 +11691,7 @@ class PostgresTodosSyncStore {
|
|
|
11546
11691
|
this.service,
|
|
11547
11692
|
entry.type,
|
|
11548
11693
|
entry.id,
|
|
11549
|
-
|
|
11694
|
+
entry.payload,
|
|
11550
11695
|
entry.updatedAt,
|
|
11551
11696
|
entry.deletedAt,
|
|
11552
11697
|
sourceMachineId,
|
|
@@ -11752,8 +11897,8 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
11752
11897
|
tasks: {
|
|
11753
11898
|
create: (input, context) => createTask2(input, store, context),
|
|
11754
11899
|
get: (id) => store.get("tasks", id),
|
|
11755
|
-
list: (filter = {}) =>
|
|
11756
|
-
count:
|
|
11900
|
+
list: (filter = {}) => store.listTasks(filter),
|
|
11901
|
+
count: (filter = {}) => store.countTasks(filter),
|
|
11757
11902
|
update: (id, input) => updateTask2(id, input, store),
|
|
11758
11903
|
delete: (id, context) => store.delete("tasks", id, context),
|
|
11759
11904
|
start: (id, agentId) => startTask2(id, agentId, store),
|
|
@@ -11808,6 +11953,7 @@ function createPostgresTodosStorageAdapter(options) {
|
|
|
11808
11953
|
audit: {
|
|
11809
11954
|
logTaskChange: (taskId, action, field, oldValue, newValue, agentId, context) => logTaskChange2(taskId, action, field, oldValue, newValue, agentId, store, context),
|
|
11810
11955
|
addComment: (input, context) => addComment2(input, store, context),
|
|
11956
|
+
getComments: async (taskId) => (await store.list("comments")).filter((comment) => comment.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
|
|
11811
11957
|
getTaskHistory: async (taskId) => (await store.list("audit_history")).filter((entry2) => entry2.task_id === taskId).sort((a, b) => a.created_at.localeCompare(b.created_at)),
|
|
11812
11958
|
getRecentActivity: async (limit = 20) => (await store.list("audit_history")).sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, limit)
|
|
11813
11959
|
},
|
|
@@ -11869,6 +12015,72 @@ class PostgresJsonRecordStore {
|
|
|
11869
12015
|
updatedAt: stringValue2(row.updated_at) ?? new Date().toISOString()
|
|
11870
12016
|
}));
|
|
11871
12017
|
}
|
|
12018
|
+
buildTaskFilterSql(filter) {
|
|
12019
|
+
const params = [this.service, "tasks"];
|
|
12020
|
+
const conds = ["service = $1", "object_type = $2", "deleted_at IS NULL"];
|
|
12021
|
+
const p = (value) => {
|
|
12022
|
+
params.push(value);
|
|
12023
|
+
return `$${params.length}`;
|
|
12024
|
+
};
|
|
12025
|
+
const inClause = (column, values) => {
|
|
12026
|
+
if (values.length === 0)
|
|
12027
|
+
return "1=0";
|
|
12028
|
+
return `${column} IN (${values.map((v) => p(v)).join(", ")})`;
|
|
12029
|
+
};
|
|
12030
|
+
if (filter.ids)
|
|
12031
|
+
conds.push(inClause("payload->>'id'", filter.ids));
|
|
12032
|
+
if (filter.project_id !== undefined)
|
|
12033
|
+
conds.push(`payload->>'project_id' = ${p(filter.project_id)}`);
|
|
12034
|
+
if (filter.parent_id !== undefined)
|
|
12035
|
+
conds.push(`payload->>'parent_id' IS NOT DISTINCT FROM ${p(filter.parent_id)}`);
|
|
12036
|
+
if (filter.plan_id !== undefined)
|
|
12037
|
+
conds.push(`payload->>'plan_id' = ${p(filter.plan_id)}`);
|
|
12038
|
+
if (filter.task_list_id !== undefined)
|
|
12039
|
+
conds.push(`payload->>'task_list_id' = ${p(filter.task_list_id)}`);
|
|
12040
|
+
if (filter.status !== undefined)
|
|
12041
|
+
conds.push(inClause("payload->>'status'", toFilterArray(filter.status)));
|
|
12042
|
+
if (filter.priority !== undefined)
|
|
12043
|
+
conds.push(inClause("payload->>'priority'", toFilterArray(filter.priority)));
|
|
12044
|
+
if (filter.assigned_to !== undefined)
|
|
12045
|
+
conds.push(`payload->>'assigned_to' = ${p(filter.assigned_to)}`);
|
|
12046
|
+
if (filter.agent_id !== undefined)
|
|
12047
|
+
conds.push(`payload->>'agent_id' = ${p(filter.agent_id)}`);
|
|
12048
|
+
if (filter.session_id !== undefined)
|
|
12049
|
+
conds.push(`payload->>'session_id' = ${p(filter.session_id)}`);
|
|
12050
|
+
if (filter.tags?.length)
|
|
12051
|
+
conds.push(`payload->'tags' @> ${p(filter.tags)}::jsonb`);
|
|
12052
|
+
if (filter.has_recurrence !== undefined) {
|
|
12053
|
+
conds.push(`(COALESCE(payload->>'recurrence_rule', '') <> '') = ${p(filter.has_recurrence)}`);
|
|
12054
|
+
}
|
|
12055
|
+
if (filter.task_type !== undefined) {
|
|
12056
|
+
conds.push(inClause("COALESCE(payload->>'task_type', '')", toFilterArray(filter.task_type)));
|
|
12057
|
+
}
|
|
12058
|
+
if (filter.include_subtasks !== true)
|
|
12059
|
+
conds.push(`(payload->>'parent_id' IS NULL OR payload->>'parent_id' = '')`);
|
|
12060
|
+
return { where: conds.join(" AND "), params };
|
|
12061
|
+
}
|
|
12062
|
+
async listTasks(filter) {
|
|
12063
|
+
await this.ensureSchema();
|
|
12064
|
+
const { where, params } = this.buildTaskFilterSql(filter);
|
|
12065
|
+
let sql = `/* todos:list-tasks */ SELECT payload FROM ${this.tableName} WHERE ${where} ${TASK_ORDER_BY}`;
|
|
12066
|
+
if (filter.limit !== undefined) {
|
|
12067
|
+
params.push(filter.limit);
|
|
12068
|
+
sql += ` LIMIT $${params.length}`;
|
|
12069
|
+
}
|
|
12070
|
+
if (filter.offset) {
|
|
12071
|
+
params.push(filter.offset);
|
|
12072
|
+
sql += ` OFFSET $${params.length}`;
|
|
12073
|
+
}
|
|
12074
|
+
const result = await this.options.client.query(sql, params);
|
|
12075
|
+
return result.rows.map((row) => payloadRecord2(row.payload));
|
|
12076
|
+
}
|
|
12077
|
+
async countTasks(filter) {
|
|
12078
|
+
await this.ensureSchema();
|
|
12079
|
+
const { where, params } = this.buildTaskFilterSql(filter);
|
|
12080
|
+
const sql = `/* todos:count-tasks */ SELECT COUNT(*)::int AS count FROM ${this.tableName} WHERE ${where}`;
|
|
12081
|
+
const result = await this.options.client.query(sql, params);
|
|
12082
|
+
return Number(result.rows[0]?.count ?? 0);
|
|
12083
|
+
}
|
|
11872
12084
|
async listTombstones() {
|
|
11873
12085
|
await this.ensureSchema();
|
|
11874
12086
|
const result = await this.options.client.query(`SELECT object_type, object_id, payload, updated_at, deleted_at, source_machine_id, version
|
|
@@ -11909,7 +12121,7 @@ class PostgresJsonRecordStore {
|
|
|
11909
12121
|
this.service,
|
|
11910
12122
|
type,
|
|
11911
12123
|
value.id,
|
|
11912
|
-
|
|
12124
|
+
jsonbParam(value),
|
|
11913
12125
|
updatedAt,
|
|
11914
12126
|
context.requestId ?? this.sourceMachineId ?? null,
|
|
11915
12127
|
numberValue2(value.version)
|
|
@@ -11971,7 +12183,7 @@ class PostgresJsonRecordStore {
|
|
|
11971
12183
|
this.service,
|
|
11972
12184
|
tombstone.object_type,
|
|
11973
12185
|
tombstone.object_id,
|
|
11974
|
-
|
|
12186
|
+
jsonbParam(tombstone.payload ?? { id: tombstone.object_id, deleted_at: deletedAt }),
|
|
11975
12187
|
updatedAt,
|
|
11976
12188
|
deletedAt,
|
|
11977
12189
|
tombstone.source_machine_id ?? context.requestId ?? this.sourceMachineId ?? null,
|
|
@@ -12150,13 +12362,15 @@ async function patchTask(task, patch, store) {
|
|
|
12150
12362
|
await store.upsert("tasks", updated);
|
|
12151
12363
|
return updated;
|
|
12152
12364
|
}
|
|
12365
|
+
var TASK_ORDER_BY = "ORDER BY CASE payload->>'priority' WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END ASC, payload->>'created_at' ASC, payload->>'id' ASC";
|
|
12366
|
+
function toFilterArray(value) {
|
|
12367
|
+
return Array.isArray(value) ? value : [value];
|
|
12368
|
+
}
|
|
12369
|
+
function jsonbParam(value) {
|
|
12370
|
+
return value;
|
|
12371
|
+
}
|
|
12153
12372
|
async function listTasks2(filter, store) {
|
|
12154
|
-
|
|
12155
|
-
tasks = tasks.filter((task) => taskMatchesFilter(task, filter));
|
|
12156
|
-
tasks.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.created_at.localeCompare(b.created_at));
|
|
12157
|
-
const offset = filter.offset ?? 0;
|
|
12158
|
-
const limit = filter.limit ?? tasks.length;
|
|
12159
|
-
return tasks.slice(offset, offset + limit);
|
|
12373
|
+
return store.listTasks(filter);
|
|
12160
12374
|
}
|
|
12161
12375
|
async function getNextTask2(filters, store) {
|
|
12162
12376
|
return (await listTasks2({ ...filters, status: "pending", limit: 1 }, store))[0] ?? null;
|
|
@@ -12464,43 +12678,6 @@ async function generateProjectPrefix(name, store) {
|
|
|
12464
12678
|
}
|
|
12465
12679
|
return candidate;
|
|
12466
12680
|
}
|
|
12467
|
-
function taskMatchesFilter(task, filter) {
|
|
12468
|
-
if (filter.ids && !filter.ids.includes(task.id))
|
|
12469
|
-
return false;
|
|
12470
|
-
if (filter.project_id !== undefined && task.project_id !== filter.project_id)
|
|
12471
|
-
return false;
|
|
12472
|
-
if (filter.parent_id !== undefined && task.parent_id !== filter.parent_id)
|
|
12473
|
-
return false;
|
|
12474
|
-
if (filter.plan_id !== undefined && task.plan_id !== filter.plan_id)
|
|
12475
|
-
return false;
|
|
12476
|
-
if (filter.task_list_id !== undefined && task.task_list_id !== filter.task_list_id)
|
|
12477
|
-
return false;
|
|
12478
|
-
if (filter.status !== undefined && !matchesOne(task.status, filter.status))
|
|
12479
|
-
return false;
|
|
12480
|
-
if (filter.priority !== undefined && !matchesOne(task.priority, filter.priority))
|
|
12481
|
-
return false;
|
|
12482
|
-
if (filter.assigned_to !== undefined && task.assigned_to !== filter.assigned_to)
|
|
12483
|
-
return false;
|
|
12484
|
-
if (filter.agent_id !== undefined && task.agent_id !== filter.agent_id)
|
|
12485
|
-
return false;
|
|
12486
|
-
if (filter.session_id !== undefined && task.session_id !== filter.session_id)
|
|
12487
|
-
return false;
|
|
12488
|
-
if (filter.tags?.length && !filter.tags.every((tag) => task.tags.includes(tag)))
|
|
12489
|
-
return false;
|
|
12490
|
-
if (filter.has_recurrence !== undefined && Boolean(task.recurrence_rule) !== filter.has_recurrence)
|
|
12491
|
-
return false;
|
|
12492
|
-
if (filter.include_subtasks !== true && task.parent_id)
|
|
12493
|
-
return false;
|
|
12494
|
-
if (filter.task_type !== undefined && !matchesOne(task.task_type ?? "", filter.task_type))
|
|
12495
|
-
return false;
|
|
12496
|
-
return true;
|
|
12497
|
-
}
|
|
12498
|
-
function matchesOne(value, expected) {
|
|
12499
|
-
return Array.isArray(expected) ? expected.includes(value) : value === expected;
|
|
12500
|
-
}
|
|
12501
|
-
function priorityRank(priority) {
|
|
12502
|
-
return { critical: 0, high: 1, medium: 2, low: 3 }[priority];
|
|
12503
|
-
}
|
|
12504
12681
|
function slugifyRaw(value) {
|
|
12505
12682
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
12506
12683
|
}
|
|
@@ -12560,11 +12737,424 @@ function numberValue2(value) {
|
|
|
12560
12737
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
12561
12738
|
}
|
|
12562
12739
|
|
|
12740
|
+
// src/storage/shadow.ts
|
|
12741
|
+
var SNAPSHOT_TO_OBJECT_TYPE = {
|
|
12742
|
+
tasks: "tasks",
|
|
12743
|
+
projects: "projects",
|
|
12744
|
+
plans: "plans",
|
|
12745
|
+
agents: "agents",
|
|
12746
|
+
taskLists: "task_lists",
|
|
12747
|
+
templates: "templates",
|
|
12748
|
+
auditHistory: "audit_history"
|
|
12749
|
+
};
|
|
12750
|
+
|
|
12751
|
+
class TodosShadowMirror {
|
|
12752
|
+
queue = [];
|
|
12753
|
+
syncStore;
|
|
12754
|
+
maxRetries;
|
|
12755
|
+
retryBaseMs;
|
|
12756
|
+
onEvent;
|
|
12757
|
+
schemaReady;
|
|
12758
|
+
pumping = false;
|
|
12759
|
+
scheduledRetries = 0;
|
|
12760
|
+
idleResolvers = [];
|
|
12761
|
+
metrics = {
|
|
12762
|
+
enqueued: 0,
|
|
12763
|
+
mirrored: 0,
|
|
12764
|
+
retries: 0,
|
|
12765
|
+
failed: 0,
|
|
12766
|
+
lastMirrorAt: null,
|
|
12767
|
+
lastLagMs: null,
|
|
12768
|
+
lastError: null
|
|
12769
|
+
};
|
|
12770
|
+
constructor(options) {
|
|
12771
|
+
const syncStore = options.syncStore ?? (options.postgresClient ? createPostgresTodosSyncStore(options.postgresClient, {
|
|
12772
|
+
...options.sourceMachineId ? { sourceMachineId: options.sourceMachineId } : {}
|
|
12773
|
+
}) : null);
|
|
12774
|
+
if (!syncStore) {
|
|
12775
|
+
throw new Error("shadow mirror requires a Postgres sync store or query client");
|
|
12776
|
+
}
|
|
12777
|
+
this.syncStore = syncStore;
|
|
12778
|
+
this.maxRetries = Math.max(0, options.maxRetries ?? 5);
|
|
12779
|
+
this.retryBaseMs = Math.max(1, options.retryBaseMs ?? 250);
|
|
12780
|
+
if (options.onEvent)
|
|
12781
|
+
this.onEvent = options.onEvent;
|
|
12782
|
+
this.schemaReady = options.ensureSchema === false ? Promise.resolve() : null;
|
|
12783
|
+
}
|
|
12784
|
+
getMetrics() {
|
|
12785
|
+
return {
|
|
12786
|
+
enabled: true,
|
|
12787
|
+
enqueued: this.metrics.enqueued,
|
|
12788
|
+
mirrored: this.metrics.mirrored,
|
|
12789
|
+
retries: this.metrics.retries,
|
|
12790
|
+
failed: this.metrics.failed,
|
|
12791
|
+
divergence: this.metrics.failed + this.queue.length + this.scheduledRetries,
|
|
12792
|
+
pending: this.queue.length + this.scheduledRetries,
|
|
12793
|
+
inFlight: this.pumping || this.scheduledRetries > 0,
|
|
12794
|
+
lastMirrorAt: this.metrics.lastMirrorAt,
|
|
12795
|
+
lastLagMs: this.metrics.lastLagMs,
|
|
12796
|
+
lastError: this.metrics.lastError
|
|
12797
|
+
};
|
|
12798
|
+
}
|
|
12799
|
+
enqueueUpsert(key, record, context) {
|
|
12800
|
+
if (!record || typeof record !== "object")
|
|
12801
|
+
return;
|
|
12802
|
+
const payload = record;
|
|
12803
|
+
if (typeof payload["id"] !== "string" || !payload["id"])
|
|
12804
|
+
return;
|
|
12805
|
+
this.metrics.enqueued += 1;
|
|
12806
|
+
this.queue.push({
|
|
12807
|
+
kind: "upsert",
|
|
12808
|
+
key,
|
|
12809
|
+
record: payload,
|
|
12810
|
+
...context ? { context } : {},
|
|
12811
|
+
enqueuedAt: Date.now(),
|
|
12812
|
+
attempts: 0
|
|
12813
|
+
});
|
|
12814
|
+
this.pump();
|
|
12815
|
+
}
|
|
12816
|
+
enqueueDelete(objectType, id, context) {
|
|
12817
|
+
if (!id)
|
|
12818
|
+
return;
|
|
12819
|
+
this.metrics.enqueued += 1;
|
|
12820
|
+
this.queue.push({
|
|
12821
|
+
kind: "delete",
|
|
12822
|
+
objectType,
|
|
12823
|
+
id,
|
|
12824
|
+
...context ? { context } : {},
|
|
12825
|
+
enqueuedAt: Date.now(),
|
|
12826
|
+
attempts: 0
|
|
12827
|
+
});
|
|
12828
|
+
this.pump();
|
|
12829
|
+
}
|
|
12830
|
+
async flush() {
|
|
12831
|
+
if (this.idle())
|
|
12832
|
+
return;
|
|
12833
|
+
await new Promise((resolve8) => {
|
|
12834
|
+
this.idleResolvers.push(resolve8);
|
|
12835
|
+
this.pump();
|
|
12836
|
+
});
|
|
12837
|
+
}
|
|
12838
|
+
idle() {
|
|
12839
|
+
return this.queue.length === 0 && !this.pumping && this.scheduledRetries === 0;
|
|
12840
|
+
}
|
|
12841
|
+
notifyIdle() {
|
|
12842
|
+
if (!this.idle())
|
|
12843
|
+
return;
|
|
12844
|
+
const resolvers = this.idleResolvers;
|
|
12845
|
+
this.idleResolvers = [];
|
|
12846
|
+
for (const resolve8 of resolvers)
|
|
12847
|
+
resolve8();
|
|
12848
|
+
}
|
|
12849
|
+
pump() {
|
|
12850
|
+
if (this.pumping)
|
|
12851
|
+
return;
|
|
12852
|
+
this.pumping = true;
|
|
12853
|
+
this.drain();
|
|
12854
|
+
}
|
|
12855
|
+
async drain() {
|
|
12856
|
+
try {
|
|
12857
|
+
while (this.queue.length > 0) {
|
|
12858
|
+
const op = this.queue.shift();
|
|
12859
|
+
await this.process(op);
|
|
12860
|
+
}
|
|
12861
|
+
} finally {
|
|
12862
|
+
this.pumping = false;
|
|
12863
|
+
this.notifyIdle();
|
|
12864
|
+
}
|
|
12865
|
+
}
|
|
12866
|
+
async process(op) {
|
|
12867
|
+
try {
|
|
12868
|
+
await this.ensureSchema();
|
|
12869
|
+
await this.push(op);
|
|
12870
|
+
const objectType = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
|
|
12871
|
+
const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
|
|
12872
|
+
const lagMs = Date.now() - op.enqueuedAt;
|
|
12873
|
+
this.metrics.mirrored += 1;
|
|
12874
|
+
this.metrics.lastMirrorAt = new Date().toISOString();
|
|
12875
|
+
this.metrics.lastLagMs = lagMs;
|
|
12876
|
+
this.onEvent?.({ type: "mirrored", objectType, id, lagMs });
|
|
12877
|
+
} catch (error) {
|
|
12878
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12879
|
+
const objectType = op.kind === "upsert" ? SNAPSHOT_TO_OBJECT_TYPE[op.key] : op.objectType;
|
|
12880
|
+
const id = op.kind === "upsert" ? String(op.record["id"]) : op.id;
|
|
12881
|
+
this.metrics.retries += 1;
|
|
12882
|
+
this.metrics.lastError = message;
|
|
12883
|
+
op.attempts += 1;
|
|
12884
|
+
if (op.attempts <= this.maxRetries) {
|
|
12885
|
+
this.onEvent?.({ type: "retry", objectType, id, attempt: op.attempts, error: message });
|
|
12886
|
+
const delay = this.retryBaseMs * 2 ** (op.attempts - 1);
|
|
12887
|
+
this.scheduledRetries += 1;
|
|
12888
|
+
setTimeout(() => {
|
|
12889
|
+
this.scheduledRetries -= 1;
|
|
12890
|
+
this.queue.push(op);
|
|
12891
|
+
this.pump();
|
|
12892
|
+
}, delay);
|
|
12893
|
+
return;
|
|
12894
|
+
}
|
|
12895
|
+
this.metrics.failed += 1;
|
|
12896
|
+
this.onEvent?.({ type: "dropped", objectType, id, error: message });
|
|
12897
|
+
}
|
|
12898
|
+
}
|
|
12899
|
+
ensureSchema() {
|
|
12900
|
+
if (!this.schemaReady) {
|
|
12901
|
+
this.schemaReady = this.syncStore.ensureSchema().catch((error) => {
|
|
12902
|
+
this.schemaReady = null;
|
|
12903
|
+
throw error;
|
|
12904
|
+
});
|
|
12905
|
+
}
|
|
12906
|
+
return this.schemaReady;
|
|
12907
|
+
}
|
|
12908
|
+
async push(op) {
|
|
12909
|
+
const snapshot = emptySnapshot();
|
|
12910
|
+
const context = op.context ?? {};
|
|
12911
|
+
if (op.kind === "upsert") {
|
|
12912
|
+
snapshot[op.key].push(op.record);
|
|
12913
|
+
} else {
|
|
12914
|
+
const now3 = new Date().toISOString();
|
|
12915
|
+
const tombstone = {
|
|
12916
|
+
object_type: op.objectType,
|
|
12917
|
+
object_id: op.id,
|
|
12918
|
+
deleted_at: now3,
|
|
12919
|
+
updated_at: now3,
|
|
12920
|
+
payload: { id: op.id, deleted_at: now3 },
|
|
12921
|
+
version: null
|
|
12922
|
+
};
|
|
12923
|
+
snapshot.tombstones = [tombstone];
|
|
12924
|
+
}
|
|
12925
|
+
await this.syncStore.pushSnapshot(snapshot, context);
|
|
12926
|
+
}
|
|
12927
|
+
}
|
|
12928
|
+
function emptySnapshot() {
|
|
12929
|
+
return {
|
|
12930
|
+
exportedAt: new Date().toISOString(),
|
|
12931
|
+
source: "sqlite",
|
|
12932
|
+
tasks: [],
|
|
12933
|
+
projects: [],
|
|
12934
|
+
projectMachinePaths: [],
|
|
12935
|
+
plans: [],
|
|
12936
|
+
agents: [],
|
|
12937
|
+
taskLists: [],
|
|
12938
|
+
templates: [],
|
|
12939
|
+
auditHistory: [],
|
|
12940
|
+
tombstones: []
|
|
12941
|
+
};
|
|
12942
|
+
}
|
|
12943
|
+
function createShadowTodosStorageAdapter(options) {
|
|
12944
|
+
const local = options.localAdapter ?? createLocalSqliteTodosStorageAdapter(options.local);
|
|
12945
|
+
const mirror = new TodosShadowMirror(options);
|
|
12946
|
+
const adapter = {
|
|
12947
|
+
...local,
|
|
12948
|
+
kind: "sqlite",
|
|
12949
|
+
capabilities: { ...local.capabilities, remotePersistence: false, sync: true },
|
|
12950
|
+
tasks: {
|
|
12951
|
+
...local.tasks,
|
|
12952
|
+
async create(input, context) {
|
|
12953
|
+
const task = await local.tasks.create(input, context);
|
|
12954
|
+
mirror.enqueueUpsert("tasks", task, context);
|
|
12955
|
+
return task;
|
|
12956
|
+
},
|
|
12957
|
+
async update(id, input, context) {
|
|
12958
|
+
const task = await local.tasks.update(id, input, context);
|
|
12959
|
+
mirror.enqueueUpsert("tasks", task, context);
|
|
12960
|
+
return task;
|
|
12961
|
+
},
|
|
12962
|
+
async start(id, agentId, context) {
|
|
12963
|
+
const task = await local.tasks.start(id, agentId, context);
|
|
12964
|
+
mirror.enqueueUpsert("tasks", task, context);
|
|
12965
|
+
return task;
|
|
12966
|
+
},
|
|
12967
|
+
async complete(id, agentId, opts, context) {
|
|
12968
|
+
const task = await local.tasks.complete(id, agentId, opts, context);
|
|
12969
|
+
mirror.enqueueUpsert("tasks", task, context);
|
|
12970
|
+
return task;
|
|
12971
|
+
},
|
|
12972
|
+
async fail(id, agentId, reason, opts, context) {
|
|
12973
|
+
const result = await local.tasks.fail(id, agentId, reason, opts, context);
|
|
12974
|
+
mirror.enqueueUpsert("tasks", result.task, context);
|
|
12975
|
+
if (result.retryTask)
|
|
12976
|
+
mirror.enqueueUpsert("tasks", result.retryTask, context);
|
|
12977
|
+
return result;
|
|
12978
|
+
},
|
|
12979
|
+
async claimNext(agentId, filters, context) {
|
|
12980
|
+
const task = await local.tasks.claimNext(agentId, filters, context);
|
|
12981
|
+
if (task)
|
|
12982
|
+
mirror.enqueueUpsert("tasks", task, context);
|
|
12983
|
+
return task;
|
|
12984
|
+
},
|
|
12985
|
+
async delete(id, context) {
|
|
12986
|
+
const deleted = await local.tasks.delete(id, context);
|
|
12987
|
+
if (deleted)
|
|
12988
|
+
mirror.enqueueDelete("tasks", id, context);
|
|
12989
|
+
return deleted;
|
|
12990
|
+
}
|
|
12991
|
+
},
|
|
12992
|
+
projects: {
|
|
12993
|
+
...local.projects,
|
|
12994
|
+
async create(input, context) {
|
|
12995
|
+
const project = await local.projects.create(input, context);
|
|
12996
|
+
mirror.enqueueUpsert("projects", project, context);
|
|
12997
|
+
return project;
|
|
12998
|
+
},
|
|
12999
|
+
async update(id, input, context) {
|
|
13000
|
+
const project = await local.projects.update(id, input, context);
|
|
13001
|
+
mirror.enqueueUpsert("projects", project, context);
|
|
13002
|
+
return project;
|
|
13003
|
+
},
|
|
13004
|
+
async delete(id, context) {
|
|
13005
|
+
const deleted = await local.projects.delete(id, context);
|
|
13006
|
+
if (deleted)
|
|
13007
|
+
mirror.enqueueDelete("projects", id, context);
|
|
13008
|
+
return deleted;
|
|
13009
|
+
}
|
|
13010
|
+
},
|
|
13011
|
+
plans: {
|
|
13012
|
+
...local.plans,
|
|
13013
|
+
async create(input, context) {
|
|
13014
|
+
const plan = await local.plans.create(input, context);
|
|
13015
|
+
mirror.enqueueUpsert("plans", plan, context);
|
|
13016
|
+
return plan;
|
|
13017
|
+
},
|
|
13018
|
+
async update(id, input, context) {
|
|
13019
|
+
const plan = await local.plans.update(id, input, context);
|
|
13020
|
+
mirror.enqueueUpsert("plans", plan, context);
|
|
13021
|
+
return plan;
|
|
13022
|
+
},
|
|
13023
|
+
async delete(id, context) {
|
|
13024
|
+
const deleted = await local.plans.delete(id, context);
|
|
13025
|
+
if (deleted)
|
|
13026
|
+
mirror.enqueueDelete("plans", id, context);
|
|
13027
|
+
return deleted;
|
|
13028
|
+
}
|
|
13029
|
+
},
|
|
13030
|
+
agents: {
|
|
13031
|
+
...local.agents,
|
|
13032
|
+
async register(input, context) {
|
|
13033
|
+
const result = await local.agents.register(input, context);
|
|
13034
|
+
if (isAgent(result))
|
|
13035
|
+
mirror.enqueueUpsert("agents", result, context);
|
|
13036
|
+
return result;
|
|
13037
|
+
},
|
|
13038
|
+
async update(id, input, context) {
|
|
13039
|
+
const agent = await local.agents.update(id, input, context);
|
|
13040
|
+
if (agent)
|
|
13041
|
+
mirror.enqueueUpsert("agents", agent, context);
|
|
13042
|
+
return agent;
|
|
13043
|
+
}
|
|
13044
|
+
},
|
|
13045
|
+
taskLists: {
|
|
13046
|
+
...local.taskLists,
|
|
13047
|
+
async create(input, context) {
|
|
13048
|
+
const list = await local.taskLists.create(input, context);
|
|
13049
|
+
mirror.enqueueUpsert("taskLists", list, context);
|
|
13050
|
+
return list;
|
|
13051
|
+
},
|
|
13052
|
+
async update(id, input, context) {
|
|
13053
|
+
const list = await local.taskLists.update(id, input, context);
|
|
13054
|
+
mirror.enqueueUpsert("taskLists", list, context);
|
|
13055
|
+
return list;
|
|
13056
|
+
},
|
|
13057
|
+
async delete(id, context) {
|
|
13058
|
+
const deleted = await local.taskLists.delete(id, context);
|
|
13059
|
+
if (deleted)
|
|
13060
|
+
mirror.enqueueDelete("task_lists", id, context);
|
|
13061
|
+
return deleted;
|
|
13062
|
+
}
|
|
13063
|
+
},
|
|
13064
|
+
templates: {
|
|
13065
|
+
...local.templates,
|
|
13066
|
+
async create(input, context) {
|
|
13067
|
+
const template = await local.templates.create(input, context);
|
|
13068
|
+
mirror.enqueueUpsert("templates", template, context);
|
|
13069
|
+
return template;
|
|
13070
|
+
},
|
|
13071
|
+
async update(id, input, context) {
|
|
13072
|
+
const template = await local.templates.update(id, input, context);
|
|
13073
|
+
if (template)
|
|
13074
|
+
mirror.enqueueUpsert("templates", template, context);
|
|
13075
|
+
return template;
|
|
13076
|
+
},
|
|
13077
|
+
async delete(id, context) {
|
|
13078
|
+
const deleted = await local.templates.delete(id, context);
|
|
13079
|
+
if (deleted)
|
|
13080
|
+
mirror.enqueueDelete("templates", id, context);
|
|
13081
|
+
return deleted;
|
|
13082
|
+
}
|
|
13083
|
+
},
|
|
13084
|
+
audit: {
|
|
13085
|
+
...local.audit,
|
|
13086
|
+
async logTaskChange(taskId, action, field, oldValue, newValue, agentId, context) {
|
|
13087
|
+
const history = await local.audit.logTaskChange(taskId, action, field, oldValue, newValue, agentId, context);
|
|
13088
|
+
mirror.enqueueUpsert("auditHistory", history, context);
|
|
13089
|
+
return history;
|
|
13090
|
+
}
|
|
13091
|
+
},
|
|
13092
|
+
shadow: mirror
|
|
13093
|
+
};
|
|
13094
|
+
if (adapter.transaction) {
|
|
13095
|
+
const localTransaction = local.transaction;
|
|
13096
|
+
adapter.transaction = (fn, context) => localTransaction(() => fn(adapter), context);
|
|
13097
|
+
}
|
|
13098
|
+
return adapter;
|
|
13099
|
+
}
|
|
13100
|
+
function isAgent(value) {
|
|
13101
|
+
return !value.conflict;
|
|
13102
|
+
}
|
|
13103
|
+
|
|
13104
|
+
// src/storage/cloud-client.ts
|
|
13105
|
+
init_config();
|
|
13106
|
+
function resolveBunSql() {
|
|
13107
|
+
const runtime = globalThis.Bun;
|
|
13108
|
+
const ctor = runtime?.SQL;
|
|
13109
|
+
if (typeof ctor !== "function") {
|
|
13110
|
+
throw new Error("Live Postgres access requires the Bun runtime (Bun.SQL). Run todos under bun, or inject a TodosPostgresQueryClient.");
|
|
13111
|
+
}
|
|
13112
|
+
return ctor;
|
|
13113
|
+
}
|
|
13114
|
+
function toRows(result) {
|
|
13115
|
+
if (Array.isArray(result))
|
|
13116
|
+
return result;
|
|
13117
|
+
if (result && typeof result === "object" && Array.isArray(result.rows)) {
|
|
13118
|
+
return result.rows;
|
|
13119
|
+
}
|
|
13120
|
+
return [];
|
|
13121
|
+
}
|
|
13122
|
+
function createTodosCloudQueryClient(url, options = {}) {
|
|
13123
|
+
const SQL = resolveBunSql();
|
|
13124
|
+
const sql = new SQL(url, {
|
|
13125
|
+
max: options.max ?? 2,
|
|
13126
|
+
idleTimeout: options.idleTimeout ?? 20,
|
|
13127
|
+
connectionTimeout: options.connectionTimeout ?? 15
|
|
13128
|
+
});
|
|
13129
|
+
return {
|
|
13130
|
+
async query(text, values = []) {
|
|
13131
|
+
const result = await sql.unsafe(text, values.length ? [...values] : []);
|
|
13132
|
+
return { rows: toRows(result) };
|
|
13133
|
+
},
|
|
13134
|
+
async close() {
|
|
13135
|
+
if (typeof sql.end === "function")
|
|
13136
|
+
await sql.end();
|
|
13137
|
+
else if (typeof sql.close === "function")
|
|
13138
|
+
await sql.close();
|
|
13139
|
+
}
|
|
13140
|
+
};
|
|
13141
|
+
}
|
|
13142
|
+
function createTodosCloudQueryClientFromEnv(env = process.env, options = {}) {
|
|
13143
|
+
const url = getTodosStorageDatabaseUrl(env);
|
|
13144
|
+
if (!url)
|
|
13145
|
+
return null;
|
|
13146
|
+
return createTodosCloudQueryClient(url, options);
|
|
13147
|
+
}
|
|
13148
|
+
|
|
12563
13149
|
// src/storage/factory.ts
|
|
13150
|
+
init_config();
|
|
12564
13151
|
function createTodosStorageAdapter(options = {}) {
|
|
12565
13152
|
const config = options.config ?? loadTodosStorageConfig(options.env);
|
|
12566
|
-
if (config.mode === "local")
|
|
13153
|
+
if (config.mode === "local") {
|
|
13154
|
+
if (isTodosShadowEnabled(options.env))
|
|
13155
|
+
return createShadowAdapter(options, config);
|
|
12567
13156
|
return createLocalSqliteTodosStorageAdapter(options.local);
|
|
13157
|
+
}
|
|
12568
13158
|
assertTodosRemoteStorageConfig(config);
|
|
12569
13159
|
const adapter = config.mode === "hybrid" ? options.hybridAdapter ?? createImplicitHybridAdapter(options) ?? options.remoteAdapter : options.remoteAdapter ?? createImplicitPostgresAdapter(options);
|
|
12570
13160
|
if (!adapter) {
|
|
@@ -12573,6 +13163,20 @@ function createTodosStorageAdapter(options = {}) {
|
|
|
12573
13163
|
assertRemoteAdapterCapabilities(adapter, config.mode);
|
|
12574
13164
|
return adapter;
|
|
12575
13165
|
}
|
|
13166
|
+
function createShadowAdapter(options, config) {
|
|
13167
|
+
assertTodosShadowConfig(config, options.env);
|
|
13168
|
+
const postgresClient = options.postgresClient ?? createTodosCloudQueryClientFromEnv(options.env);
|
|
13169
|
+
if (!options.postgresSyncStore && !postgresClient) {
|
|
13170
|
+
throw new Error("shadow mirror requires a remote database URL or an injected Postgres client");
|
|
13171
|
+
}
|
|
13172
|
+
return createShadowTodosStorageAdapter({
|
|
13173
|
+
...options.shadow ?? {},
|
|
13174
|
+
...options.local ? { local: options.local } : {},
|
|
13175
|
+
...options.postgresSyncStore ? { syncStore: options.postgresSyncStore } : {},
|
|
13176
|
+
...postgresClient ? { postgresClient } : {},
|
|
13177
|
+
...options.hybrid?.sourceMachineId ? { sourceMachineId: options.hybrid.sourceMachineId } : {}
|
|
13178
|
+
});
|
|
13179
|
+
}
|
|
12576
13180
|
function createImplicitPostgresAdapter(options) {
|
|
12577
13181
|
if (!options.postgresClient)
|
|
12578
13182
|
return null;
|
|
@@ -12599,6 +13203,326 @@ function assertRemoteAdapterCapabilities(adapter, mode) {
|
|
|
12599
13203
|
throw new Error("hybrid storage adapter must also set capabilities.localPersistence=true");
|
|
12600
13204
|
}
|
|
12601
13205
|
}
|
|
13206
|
+
// src/storage/shadow-outbox.ts
|
|
13207
|
+
init_shadow_outbox_schema();
|
|
13208
|
+
init_shadow_outbox_schema();
|
|
13209
|
+
var MAX_BACKOFF_MS = 5 * 60000;
|
|
13210
|
+
|
|
13211
|
+
class TodosShadowOutbox {
|
|
13212
|
+
db;
|
|
13213
|
+
syncStore;
|
|
13214
|
+
local;
|
|
13215
|
+
maxRetries;
|
|
13216
|
+
retryBaseMs;
|
|
13217
|
+
batchSize;
|
|
13218
|
+
onEvent;
|
|
13219
|
+
schemaReady;
|
|
13220
|
+
loopTimer = null;
|
|
13221
|
+
draining = false;
|
|
13222
|
+
metrics = {
|
|
13223
|
+
mirrored: 0,
|
|
13224
|
+
retries: 0,
|
|
13225
|
+
lastMirrorAt: null,
|
|
13226
|
+
lastLagMs: null,
|
|
13227
|
+
lastError: null
|
|
13228
|
+
};
|
|
13229
|
+
constructor(options) {
|
|
13230
|
+
this.db = options.db;
|
|
13231
|
+
const syncStore = options.syncStore ?? (options.postgresClient ? createPostgresTodosSyncStore(options.postgresClient, {
|
|
13232
|
+
...options.sourceMachineId ? { sourceMachineId: options.sourceMachineId } : {}
|
|
13233
|
+
}) : null);
|
|
13234
|
+
if (!syncStore) {
|
|
13235
|
+
throw new Error("shadow outbox requires a Postgres sync store or query client");
|
|
13236
|
+
}
|
|
13237
|
+
this.syncStore = syncStore;
|
|
13238
|
+
this.local = options.localAdapter ?? createLocalSqliteTodosStorageAdapter({ db: options.db });
|
|
13239
|
+
this.maxRetries = Math.max(0, options.maxRetries ?? 8);
|
|
13240
|
+
this.retryBaseMs = Math.max(1, options.retryBaseMs ?? 500);
|
|
13241
|
+
this.batchSize = Math.max(1, options.batchSize ?? 50);
|
|
13242
|
+
if (options.onEvent)
|
|
13243
|
+
this.onEvent = options.onEvent;
|
|
13244
|
+
this.schemaReady = options.ensureSchema === false ? Promise.resolve() : null;
|
|
13245
|
+
}
|
|
13246
|
+
install() {
|
|
13247
|
+
installShadowOutboxSchema(this.db);
|
|
13248
|
+
}
|
|
13249
|
+
getStats() {
|
|
13250
|
+
const pending = this.countByStatus("pending");
|
|
13251
|
+
const failed = this.countByStatus("failed");
|
|
13252
|
+
return {
|
|
13253
|
+
enabled: true,
|
|
13254
|
+
durable: true,
|
|
13255
|
+
pending,
|
|
13256
|
+
failed,
|
|
13257
|
+
depth: pending + failed,
|
|
13258
|
+
mirrored: this.metrics.mirrored,
|
|
13259
|
+
retries: this.metrics.retries,
|
|
13260
|
+
lastMirrorAt: this.metrics.lastMirrorAt,
|
|
13261
|
+
lastLagMs: this.metrics.lastLagMs,
|
|
13262
|
+
lastError: this.metrics.lastError,
|
|
13263
|
+
draining: this.loopTimer !== null
|
|
13264
|
+
};
|
|
13265
|
+
}
|
|
13266
|
+
countByStatus(status) {
|
|
13267
|
+
const row = this.db.query(`SELECT count(*) AS n FROM shadow_outbox WHERE status=?`).get(status);
|
|
13268
|
+
return row?.n ?? 0;
|
|
13269
|
+
}
|
|
13270
|
+
startLoop(intervalMs = 2000) {
|
|
13271
|
+
if (this.loopTimer)
|
|
13272
|
+
return;
|
|
13273
|
+
this.loopTimer = setInterval(() => {
|
|
13274
|
+
this.drainOnce().catch(() => {});
|
|
13275
|
+
}, Math.max(250, intervalMs));
|
|
13276
|
+
if (typeof this.loopTimer.unref === "function")
|
|
13277
|
+
this.loopTimer.unref();
|
|
13278
|
+
}
|
|
13279
|
+
stopLoop() {
|
|
13280
|
+
if (this.loopTimer) {
|
|
13281
|
+
clearInterval(this.loopTimer);
|
|
13282
|
+
this.loopTimer = null;
|
|
13283
|
+
}
|
|
13284
|
+
}
|
|
13285
|
+
async flush(timeoutMs = 15000) {
|
|
13286
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
13287
|
+
while (Date.now() < deadline) {
|
|
13288
|
+
const processed = await this.drainOnce();
|
|
13289
|
+
if (this.countByStatus("pending") === 0)
|
|
13290
|
+
break;
|
|
13291
|
+
if (processed === 0) {
|
|
13292
|
+
const remaining = deadline - Date.now();
|
|
13293
|
+
if (remaining <= 0)
|
|
13294
|
+
break;
|
|
13295
|
+
await new Promise((resolve8) => setTimeout(resolve8, Math.min(200, remaining)));
|
|
13296
|
+
}
|
|
13297
|
+
}
|
|
13298
|
+
return this.getStats();
|
|
13299
|
+
}
|
|
13300
|
+
async drainOnce() {
|
|
13301
|
+
if (this.draining)
|
|
13302
|
+
return 0;
|
|
13303
|
+
this.draining = true;
|
|
13304
|
+
try {
|
|
13305
|
+
await this.ensureSchema();
|
|
13306
|
+
const now3 = Date.now();
|
|
13307
|
+
const rows = this.db.query(`SELECT seq, object_type, object_id, op, enqueued_at, attempts, revision
|
|
13308
|
+
FROM shadow_outbox
|
|
13309
|
+
WHERE status='pending' AND next_attempt_at <= ?
|
|
13310
|
+
ORDER BY seq ASC
|
|
13311
|
+
LIMIT ?`).all(now3, this.batchSize);
|
|
13312
|
+
if (rows.length === 0)
|
|
13313
|
+
return 0;
|
|
13314
|
+
for (const row of rows) {
|
|
13315
|
+
await this.processRow(row);
|
|
13316
|
+
}
|
|
13317
|
+
return rows.length;
|
|
13318
|
+
} catch {
|
|
13319
|
+
return 0;
|
|
13320
|
+
} finally {
|
|
13321
|
+
this.draining = false;
|
|
13322
|
+
}
|
|
13323
|
+
}
|
|
13324
|
+
async processRow(row) {
|
|
13325
|
+
try {
|
|
13326
|
+
const snapshot = await this.buildSnapshot(row);
|
|
13327
|
+
await this.syncStore.pushSnapshot(snapshot, {});
|
|
13328
|
+
const lagMs = Date.now() - row.enqueued_at;
|
|
13329
|
+
this.db.run(`DELETE FROM shadow_outbox WHERE seq=? AND revision=?`, [row.seq, row.revision]);
|
|
13330
|
+
this.metrics.mirrored += 1;
|
|
13331
|
+
this.metrics.lastMirrorAt = new Date().toISOString();
|
|
13332
|
+
this.metrics.lastLagMs = lagMs;
|
|
13333
|
+
this.onEvent?.({ type: "mirrored", objectType: row.object_type, id: row.object_id, lagMs });
|
|
13334
|
+
} catch (error) {
|
|
13335
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13336
|
+
this.metrics.retries += 1;
|
|
13337
|
+
this.metrics.lastError = message;
|
|
13338
|
+
const attempts = row.attempts + 1;
|
|
13339
|
+
if (attempts > this.maxRetries) {
|
|
13340
|
+
this.db.run(`UPDATE shadow_outbox SET attempts=?, last_error=?, status='failed' WHERE seq=? AND revision=?`, [attempts, message, row.seq, row.revision]);
|
|
13341
|
+
this.onEvent?.({ type: "parked", objectType: row.object_type, id: row.object_id, error: message });
|
|
13342
|
+
return;
|
|
13343
|
+
}
|
|
13344
|
+
const delay = Math.min(MAX_BACKOFF_MS, this.retryBaseMs * 2 ** (attempts - 1));
|
|
13345
|
+
this.db.run(`UPDATE shadow_outbox SET attempts=?, next_attempt_at=?, last_error=? WHERE seq=? AND revision=?`, [attempts, Date.now() + delay, message, row.seq, row.revision]);
|
|
13346
|
+
this.onEvent?.({ type: "retry", objectType: row.object_type, id: row.object_id, attempt: attempts, error: message });
|
|
13347
|
+
}
|
|
13348
|
+
}
|
|
13349
|
+
async buildSnapshot(row) {
|
|
13350
|
+
const snapshot = emptySnapshot2();
|
|
13351
|
+
if (row.op === "delete") {
|
|
13352
|
+
snapshot.tombstones = [this.tombstone(row.object_type, row.object_id)];
|
|
13353
|
+
return snapshot;
|
|
13354
|
+
}
|
|
13355
|
+
const record = await this.readCurrent(row.object_type, row.object_id);
|
|
13356
|
+
if (!record) {
|
|
13357
|
+
snapshot.tombstones = [this.tombstone(row.object_type, row.object_id)];
|
|
13358
|
+
return snapshot;
|
|
13359
|
+
}
|
|
13360
|
+
switch (row.object_type) {
|
|
13361
|
+
case "tasks":
|
|
13362
|
+
snapshot.tasks.push(record);
|
|
13363
|
+
break;
|
|
13364
|
+
case "projects":
|
|
13365
|
+
snapshot.projects.push(record);
|
|
13366
|
+
break;
|
|
13367
|
+
case "project_machine_paths":
|
|
13368
|
+
snapshot.projectMachinePaths?.push(record);
|
|
13369
|
+
break;
|
|
13370
|
+
case "plans":
|
|
13371
|
+
snapshot.plans.push(record);
|
|
13372
|
+
break;
|
|
13373
|
+
case "agents":
|
|
13374
|
+
snapshot.agents.push(record);
|
|
13375
|
+
break;
|
|
13376
|
+
case "task_lists":
|
|
13377
|
+
snapshot.taskLists.push(record);
|
|
13378
|
+
break;
|
|
13379
|
+
case "templates":
|
|
13380
|
+
snapshot.templates.push(record);
|
|
13381
|
+
break;
|
|
13382
|
+
case "audit_history":
|
|
13383
|
+
snapshot.auditHistory.push(record);
|
|
13384
|
+
break;
|
|
13385
|
+
default:
|
|
13386
|
+
break;
|
|
13387
|
+
}
|
|
13388
|
+
return snapshot;
|
|
13389
|
+
}
|
|
13390
|
+
async readCurrent(objectType, id) {
|
|
13391
|
+
switch (objectType) {
|
|
13392
|
+
case "tasks":
|
|
13393
|
+
return await this.local.tasks.get(id);
|
|
13394
|
+
case "projects":
|
|
13395
|
+
return await this.local.projects.get(id);
|
|
13396
|
+
case "project_machine_paths": {
|
|
13397
|
+
const raw = this.db.query(`SELECT * FROM project_machine_paths WHERE id=?`).get(id);
|
|
13398
|
+
return raw ?? null;
|
|
13399
|
+
}
|
|
13400
|
+
case "plans":
|
|
13401
|
+
return await this.local.plans.get(id);
|
|
13402
|
+
case "agents":
|
|
13403
|
+
return await this.local.agents.get(id);
|
|
13404
|
+
case "task_lists":
|
|
13405
|
+
return await this.local.taskLists.get(id);
|
|
13406
|
+
case "templates":
|
|
13407
|
+
return await this.local.templates.get(id);
|
|
13408
|
+
case "audit_history": {
|
|
13409
|
+
const raw = this.db.query(`SELECT * FROM task_history WHERE id=?`).get(id);
|
|
13410
|
+
return raw ?? null;
|
|
13411
|
+
}
|
|
13412
|
+
default:
|
|
13413
|
+
return null;
|
|
13414
|
+
}
|
|
13415
|
+
}
|
|
13416
|
+
tombstone(objectType, id) {
|
|
13417
|
+
const now3 = new Date().toISOString();
|
|
13418
|
+
return {
|
|
13419
|
+
object_type: objectType,
|
|
13420
|
+
object_id: id,
|
|
13421
|
+
deleted_at: now3,
|
|
13422
|
+
updated_at: now3,
|
|
13423
|
+
payload: { id, deleted_at: now3 },
|
|
13424
|
+
version: null
|
|
13425
|
+
};
|
|
13426
|
+
}
|
|
13427
|
+
ensureSchema() {
|
|
13428
|
+
if (!this.schemaReady) {
|
|
13429
|
+
this.schemaReady = this.syncStore.ensureSchema().catch((error) => {
|
|
13430
|
+
this.schemaReady = null;
|
|
13431
|
+
throw error;
|
|
13432
|
+
});
|
|
13433
|
+
}
|
|
13434
|
+
return this.schemaReady;
|
|
13435
|
+
}
|
|
13436
|
+
}
|
|
13437
|
+
function emptySnapshot2() {
|
|
13438
|
+
return {
|
|
13439
|
+
exportedAt: new Date().toISOString(),
|
|
13440
|
+
source: "sqlite",
|
|
13441
|
+
tasks: [],
|
|
13442
|
+
projects: [],
|
|
13443
|
+
projectMachinePaths: [],
|
|
13444
|
+
plans: [],
|
|
13445
|
+
agents: [],
|
|
13446
|
+
taskLists: [],
|
|
13447
|
+
templates: [],
|
|
13448
|
+
auditHistory: [],
|
|
13449
|
+
tombstones: []
|
|
13450
|
+
};
|
|
13451
|
+
}
|
|
13452
|
+
function createTodosShadowOutbox(options) {
|
|
13453
|
+
const outbox = new TodosShadowOutbox(options);
|
|
13454
|
+
outbox.install();
|
|
13455
|
+
return outbox;
|
|
13456
|
+
}
|
|
13457
|
+
// src/storage/shadow-runtime.ts
|
|
13458
|
+
init_config();
|
|
13459
|
+
var _capturedDb = null;
|
|
13460
|
+
var _outbox = null;
|
|
13461
|
+
var _cloud = null;
|
|
13462
|
+
var _exitRegistered = false;
|
|
13463
|
+
function maybeInstallShadowCapture2(db, env = process.env) {
|
|
13464
|
+
if (!isTodosShadowEnabled(env))
|
|
13465
|
+
return false;
|
|
13466
|
+
if (_capturedDb === db)
|
|
13467
|
+
return true;
|
|
13468
|
+
installShadowOutboxSchema(db);
|
|
13469
|
+
_capturedDb = db;
|
|
13470
|
+
return true;
|
|
13471
|
+
}
|
|
13472
|
+
function getRuntimeShadowOutbox(db, env = process.env) {
|
|
13473
|
+
if (!isTodosShadowEnabled(env)) {
|
|
13474
|
+
throw new Error(`dual-write shadow is disabled: set ${getTodosStorageShadowEnvName(env)}=1 to enable it`);
|
|
13475
|
+
}
|
|
13476
|
+
if (_outbox && _capturedDb === db)
|
|
13477
|
+
return _outbox;
|
|
13478
|
+
const cloud = createTodosCloudQueryClientFromEnv(env);
|
|
13479
|
+
if (!cloud) {
|
|
13480
|
+
throw new Error(`dual-write shadow drain requires a remote Postgres DSN in ${getTodosStorageDatabaseEnv(env)}`);
|
|
13481
|
+
}
|
|
13482
|
+
_cloud = cloud;
|
|
13483
|
+
installShadowOutboxSchema(db);
|
|
13484
|
+
_capturedDb = db;
|
|
13485
|
+
_outbox = new TodosShadowOutbox({ db, postgresClient: cloud });
|
|
13486
|
+
return _outbox;
|
|
13487
|
+
}
|
|
13488
|
+
function startRuntimeShadowDrain(db, env = process.env) {
|
|
13489
|
+
if (!isTodosShadowEnabled(env))
|
|
13490
|
+
return;
|
|
13491
|
+
maybeInstallShadowCapture2(db, env);
|
|
13492
|
+
try {
|
|
13493
|
+
const outbox = getRuntimeShadowOutbox(db, env);
|
|
13494
|
+
outbox.startLoop();
|
|
13495
|
+
registerShadowExitFlush(db, env);
|
|
13496
|
+
} catch (error) {
|
|
13497
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
13498
|
+
console.error(`[todos] shadow drain not started: ${message}`);
|
|
13499
|
+
}
|
|
13500
|
+
}
|
|
13501
|
+
function registerShadowExitFlush(db, env = process.env) {
|
|
13502
|
+
if (_exitRegistered)
|
|
13503
|
+
return;
|
|
13504
|
+
if (!isTodosShadowEnabled(env))
|
|
13505
|
+
return;
|
|
13506
|
+
_exitRegistered = true;
|
|
13507
|
+
const flush = async () => {
|
|
13508
|
+
try {
|
|
13509
|
+
const outbox = getRuntimeShadowOutbox(db, env);
|
|
13510
|
+
await outbox.flush(5000);
|
|
13511
|
+
} catch {} finally {
|
|
13512
|
+
await closeRuntimeShadowCloud();
|
|
13513
|
+
}
|
|
13514
|
+
};
|
|
13515
|
+
process.once("beforeExit", () => {
|
|
13516
|
+
flush();
|
|
13517
|
+
});
|
|
13518
|
+
}
|
|
13519
|
+
async function closeRuntimeShadowCloud() {
|
|
13520
|
+
if (_cloud) {
|
|
13521
|
+
const cloud = _cloud;
|
|
13522
|
+
_cloud = null;
|
|
13523
|
+
await cloud.close().catch(() => {});
|
|
13524
|
+
}
|
|
13525
|
+
}
|
|
12602
13526
|
// src/storage/s3-artifacts.ts
|
|
12603
13527
|
import { createHash as createHash3, createHmac as createHmac2 } from "crypto";
|
|
12604
13528
|
function createTodosS3ArtifactStore(options) {
|
|
@@ -13027,14 +13951,20 @@ function mediaType(metadata) {
|
|
|
13027
13951
|
}
|
|
13028
13952
|
export {
|
|
13029
13953
|
uploadRunArtifactsToS3,
|
|
13954
|
+
startRuntimeShadowDrain,
|
|
13030
13955
|
signAwsV4Request,
|
|
13956
|
+
registerShadowExitFlush,
|
|
13031
13957
|
postgresTodosSyncSchemaSql,
|
|
13032
13958
|
planRunArtifactsS3Sync,
|
|
13033
13959
|
parseStorageMode,
|
|
13960
|
+
maybeInstallShadowCapture2 as maybeInstallShadowCapture,
|
|
13034
13961
|
loadTodosStorageConfig,
|
|
13035
13962
|
loadStorageConfig,
|
|
13963
|
+
isTodosShadowEnabled,
|
|
13036
13964
|
isTodosRemoteStorageEnabled,
|
|
13965
|
+
installShadowOutboxSchema,
|
|
13037
13966
|
importSqliteTodosStorageSnapshot,
|
|
13967
|
+
getTodosStorageShadowEnvName,
|
|
13038
13968
|
getTodosStorageMode,
|
|
13039
13969
|
getTodosStorageEnvName,
|
|
13040
13970
|
getTodosStorageDatabaseUrl,
|
|
@@ -13042,18 +13972,27 @@ export {
|
|
|
13042
13972
|
getStorageMode,
|
|
13043
13973
|
getStorageDatabaseUrl,
|
|
13044
13974
|
getStorageDatabaseEnv,
|
|
13975
|
+
getRuntimeShadowOutbox,
|
|
13045
13976
|
getCanonicalTodosRdsConfig,
|
|
13046
13977
|
exportSqliteTodosStorageSnapshot,
|
|
13047
13978
|
downloadRunArtifactsFromS3,
|
|
13048
13979
|
createTodosStorageAdapter,
|
|
13980
|
+
createTodosShadowOutbox,
|
|
13049
13981
|
createTodosS3ArtifactStore,
|
|
13982
|
+
createTodosCloudQueryClientFromEnv,
|
|
13983
|
+
createTodosCloudQueryClient,
|
|
13984
|
+
createShadowTodosStorageAdapter,
|
|
13050
13985
|
createPostgresTodosSyncStore,
|
|
13051
13986
|
createPostgresTodosStorageAdapter,
|
|
13052
13987
|
createLocalSqliteTodosStorageAdapter,
|
|
13053
13988
|
createHybridTodosStorageAdapter,
|
|
13989
|
+
closeRuntimeShadowCloud,
|
|
13054
13990
|
buildS3ObjectUrl,
|
|
13055
13991
|
buildS3ObjectKey,
|
|
13992
|
+
assertTodosShadowConfig,
|
|
13056
13993
|
assertTodosRemoteStorageConfig,
|
|
13994
|
+
TodosShadowOutbox,
|
|
13995
|
+
TodosShadowMirror,
|
|
13057
13996
|
TODOS_STORAGE_TABLES,
|
|
13058
13997
|
TODOS_STORAGE_FALLBACK_ENV,
|
|
13059
13998
|
TODOS_STORAGE_ENV,
|