@hasna/loops 0.4.3 → 0.4.4
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 +76 -0
- package/README.md +6 -1
- package/dist/api/index.js +2 -2
- package/dist/cli/index.js +928 -418
- package/dist/daemon/index.js +188 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +759 -70
- package/dist/lib/migration.d.ts +109 -0
- package/dist/lib/mode.js +2 -2
- package/dist/lib/route/todos-cli.d.ts +0 -1
- package/dist/lib/route/types.d.ts +0 -10
- package/dist/lib/storage/index.js +186 -2
- package/dist/lib/storage/sqlite.js +186 -2
- package/dist/lib/store.d.ts +41 -0
- package/dist/lib/store.js +186 -2
- package/dist/lib/template-kit.d.ts +8 -10
- package/dist/lib/templates.d.ts +0 -1
- package/dist/mcp/index.js +188 -4
- package/dist/runner/index.js +2 -2
- package/dist/sdk/index.d.ts +9 -0
- package/dist/sdk/index.js +983 -3
- package/docs/DEPLOYMENT_MODES.md +73 -5
- package/docs/USAGE.md +52 -1
- package/package.json +2 -2
package/dist/cli/index.js
CHANGED
|
@@ -1525,7 +1525,7 @@ class Store {
|
|
|
1525
1525
|
}
|
|
1526
1526
|
const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
|
|
1527
1527
|
for (const migration of this.migrations()) {
|
|
1528
|
-
if (!migration.baseline &&
|
|
1528
|
+
if (!migration.baseline && applied.has(migration.id))
|
|
1529
1529
|
continue;
|
|
1530
1530
|
migration.apply();
|
|
1531
1531
|
if (!applied.has(migration.id)) {
|
|
@@ -1578,7 +1578,6 @@ class Store {
|
|
|
1578
1578
|
},
|
|
1579
1579
|
{
|
|
1580
1580
|
id: "0007_run_claim_tokens",
|
|
1581
|
-
reapply: true,
|
|
1582
1581
|
apply: () => {
|
|
1583
1582
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1584
1583
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
@@ -1643,6 +1642,7 @@ class Store {
|
|
|
1643
1642
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
|
|
1644
1643
|
CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
|
|
1645
1644
|
CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
|
|
1645
|
+
CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL;
|
|
1646
1646
|
|
|
1647
1647
|
CREATE TABLE IF NOT EXISTS daemon_lease (
|
|
1648
1648
|
id TEXT PRIMARY KEY,
|
|
@@ -3843,6 +3843,190 @@ class Store {
|
|
|
3843
3843
|
const row = status ? this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = ?").get(status) : this.db.query("SELECT COUNT(*) AS count FROM loop_runs").get();
|
|
3844
3844
|
return row?.count ?? 0;
|
|
3845
3845
|
}
|
|
3846
|
+
exportMigrationRows(opts = {}) {
|
|
3847
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
3848
|
+
const workflows = this.db.query("SELECT * FROM workflow_specs ORDER BY created_at ASC, id ASC").all().map(rowToWorkflow);
|
|
3849
|
+
const loops = this.db.query("SELECT * FROM loops ORDER BY created_at ASC, id ASC").all().map(rowToLoop);
|
|
3850
|
+
const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
|
|
3851
|
+
return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
|
|
3852
|
+
}
|
|
3853
|
+
countTable(table) {
|
|
3854
|
+
const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
3855
|
+
return row?.count ?? 0;
|
|
3856
|
+
}
|
|
3857
|
+
migrationChecks() {
|
|
3858
|
+
const now = nowIso();
|
|
3859
|
+
return {
|
|
3860
|
+
unsupportedCounts: {
|
|
3861
|
+
workflowInvocations: this.countTable("workflow_invocations"),
|
|
3862
|
+
workflowWorkItems: this.countTable("workflow_work_items"),
|
|
3863
|
+
workflowRuns: this.countTable("workflow_runs"),
|
|
3864
|
+
workflowStepRuns: this.countTable("workflow_step_runs"),
|
|
3865
|
+
workflowEvents: this.countTable("workflow_events"),
|
|
3866
|
+
goals: this.countTable("goals"),
|
|
3867
|
+
goalPlanNodes: this.countTable("goal_plan_nodes"),
|
|
3868
|
+
goalRuns: this.countTable("goal_runs")
|
|
3869
|
+
},
|
|
3870
|
+
volatileCounts: {
|
|
3871
|
+
daemonLeases: this.countTable("daemon_lease"),
|
|
3872
|
+
activeDaemonLeases: this.db.query("SELECT COUNT(*) AS count FROM daemon_lease WHERE expires_at > ?").get(now)?.count ?? 0,
|
|
3873
|
+
runningLoopRuns: this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3874
|
+
runningWorkflowRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3875
|
+
runningWorkflowStepRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3876
|
+
leasedWorkflowWorkItems: this.db.query("SELECT COUNT(*) AS count FROM workflow_work_items WHERE lease_expires_at IS NOT NULL OR status IN ('admitted', 'running')").get()?.count ?? 0
|
|
3877
|
+
}
|
|
3878
|
+
};
|
|
3879
|
+
}
|
|
3880
|
+
upsertMigrationWorkflow(workflow, opts = {}) {
|
|
3881
|
+
const existing = this.getWorkflow(workflow.id);
|
|
3882
|
+
if (existing && !opts.replace)
|
|
3883
|
+
return existing;
|
|
3884
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
3885
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)
|
|
3886
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3887
|
+
name=$name,
|
|
3888
|
+
description=$description,
|
|
3889
|
+
version=$version,
|
|
3890
|
+
status=$status,
|
|
3891
|
+
goal_json=$goal,
|
|
3892
|
+
steps_json=$steps,
|
|
3893
|
+
created_at=$created,
|
|
3894
|
+
updated_at=$updated`).run({
|
|
3895
|
+
$id: workflow.id,
|
|
3896
|
+
$name: workflow.name,
|
|
3897
|
+
$description: workflow.description ?? null,
|
|
3898
|
+
$version: workflow.version,
|
|
3899
|
+
$status: workflow.status,
|
|
3900
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
3901
|
+
$steps: JSON.stringify(workflow.steps),
|
|
3902
|
+
$created: workflow.createdAt,
|
|
3903
|
+
$updated: workflow.updatedAt
|
|
3904
|
+
});
|
|
3905
|
+
const imported = this.getWorkflow(workflow.id);
|
|
3906
|
+
if (!imported)
|
|
3907
|
+
throw new Error(`workflow not found after migration import: ${workflow.id}`);
|
|
3908
|
+
return imported;
|
|
3909
|
+
}
|
|
3910
|
+
upsertMigrationLoop(loop, opts = {}) {
|
|
3911
|
+
const existing = this.getLoop(loop.id);
|
|
3912
|
+
if (existing && !opts.replace)
|
|
3913
|
+
return existing;
|
|
3914
|
+
this.assertNoNestedWorkflowGoal(loop.target, loop.goal);
|
|
3915
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
|
|
3916
|
+
goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
|
|
3917
|
+
retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
3918
|
+
VALUES ($id, $name, $description, $status, $archivedAt, $archivedFromStatus, $schedule, $target,
|
|
3919
|
+
$goal, $machine, $nextRun, $retrySlot, $catchUp, $catchUpLimit, $overlap, $maxAttempts,
|
|
3920
|
+
$retryDelay, $leaseMs, $expiresAt, $created, $updated)
|
|
3921
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3922
|
+
name=$name,
|
|
3923
|
+
description=$description,
|
|
3924
|
+
status=$status,
|
|
3925
|
+
archived_at=$archivedAt,
|
|
3926
|
+
archived_from_status=$archivedFromStatus,
|
|
3927
|
+
schedule_json=$schedule,
|
|
3928
|
+
target_json=$target,
|
|
3929
|
+
goal_json=$goal,
|
|
3930
|
+
machine_json=$machine,
|
|
3931
|
+
next_run_at=$nextRun,
|
|
3932
|
+
retry_scheduled_for=$retrySlot,
|
|
3933
|
+
catch_up=$catchUp,
|
|
3934
|
+
catch_up_limit=$catchUpLimit,
|
|
3935
|
+
overlap=$overlap,
|
|
3936
|
+
max_attempts=$maxAttempts,
|
|
3937
|
+
retry_delay_ms=$retryDelay,
|
|
3938
|
+
lease_ms=$leaseMs,
|
|
3939
|
+
expires_at=$expiresAt,
|
|
3940
|
+
created_at=$created,
|
|
3941
|
+
updated_at=$updated`).run({
|
|
3942
|
+
$id: loop.id,
|
|
3943
|
+
$name: loop.name,
|
|
3944
|
+
$description: loop.description ?? null,
|
|
3945
|
+
$status: loop.status,
|
|
3946
|
+
$archivedAt: loop.archivedAt ?? null,
|
|
3947
|
+
$archivedFromStatus: loop.archivedFromStatus ?? null,
|
|
3948
|
+
$schedule: JSON.stringify(loop.schedule),
|
|
3949
|
+
$target: JSON.stringify(loop.target),
|
|
3950
|
+
$goal: loop.goal ? JSON.stringify(loop.goal) : null,
|
|
3951
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
3952
|
+
$nextRun: loop.nextRunAt ?? null,
|
|
3953
|
+
$retrySlot: loop.retryScheduledFor ?? null,
|
|
3954
|
+
$catchUp: loop.catchUp,
|
|
3955
|
+
$catchUpLimit: loop.catchUpLimit,
|
|
3956
|
+
$overlap: loop.overlap,
|
|
3957
|
+
$maxAttempts: loop.maxAttempts,
|
|
3958
|
+
$retryDelay: loop.retryDelayMs,
|
|
3959
|
+
$leaseMs: loop.leaseMs,
|
|
3960
|
+
$expiresAt: loop.expiresAt ?? null,
|
|
3961
|
+
$created: loop.createdAt,
|
|
3962
|
+
$updated: loop.updatedAt
|
|
3963
|
+
});
|
|
3964
|
+
const imported = this.getLoop(loop.id);
|
|
3965
|
+
if (!imported)
|
|
3966
|
+
throw new Error(`loop not found after migration import: ${loop.id}`);
|
|
3967
|
+
return imported;
|
|
3968
|
+
}
|
|
3969
|
+
upsertMigrationRun(run, opts = {}) {
|
|
3970
|
+
if (run.status === "running")
|
|
3971
|
+
throw new ValidationError(`cannot import running run ${run.id}`);
|
|
3972
|
+
const existing = this.getRun(run.id);
|
|
3973
|
+
if (existing && !opts.replace)
|
|
3974
|
+
return existing;
|
|
3975
|
+
this.db.query(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
3976
|
+
claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
|
|
3977
|
+
stdout, stderr, error, goal_run_id, created_at, updated_at)
|
|
3978
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, $startedAt, $finishedAt,
|
|
3979
|
+
$claimedBy, NULL, $leaseExpiresAt, $pid, $pgid, $processStartedAt, $exitCode, $durationMs,
|
|
3980
|
+
$stdout, $stderr, $error, $goalRunId, $created, $updated)
|
|
3981
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3982
|
+
loop_id=$loopId,
|
|
3983
|
+
loop_name=$loopName,
|
|
3984
|
+
scheduled_for=$scheduledFor,
|
|
3985
|
+
attempt=$attempt,
|
|
3986
|
+
status=$status,
|
|
3987
|
+
started_at=$startedAt,
|
|
3988
|
+
finished_at=$finishedAt,
|
|
3989
|
+
claimed_by=$claimedBy,
|
|
3990
|
+
claim_token=NULL,
|
|
3991
|
+
lease_expires_at=$leaseExpiresAt,
|
|
3992
|
+
pid=$pid,
|
|
3993
|
+
pgid=$pgid,
|
|
3994
|
+
process_started_at=$processStartedAt,
|
|
3995
|
+
exit_code=$exitCode,
|
|
3996
|
+
duration_ms=$durationMs,
|
|
3997
|
+
stdout=$stdout,
|
|
3998
|
+
stderr=$stderr,
|
|
3999
|
+
error=$error,
|
|
4000
|
+
goal_run_id=$goalRunId,
|
|
4001
|
+
created_at=$created,
|
|
4002
|
+
updated_at=$updated`).run({
|
|
4003
|
+
$id: run.id,
|
|
4004
|
+
$loopId: run.loopId,
|
|
4005
|
+
$loopName: run.loopName,
|
|
4006
|
+
$scheduledFor: run.scheduledFor,
|
|
4007
|
+
$attempt: run.attempt,
|
|
4008
|
+
$status: run.status,
|
|
4009
|
+
$startedAt: run.startedAt ?? null,
|
|
4010
|
+
$finishedAt: run.finishedAt ?? null,
|
|
4011
|
+
$claimedBy: run.claimedBy ?? null,
|
|
4012
|
+
$leaseExpiresAt: run.leaseExpiresAt ?? null,
|
|
4013
|
+
$pid: run.pid ?? null,
|
|
4014
|
+
$pgid: run.pgid ?? null,
|
|
4015
|
+
$processStartedAt: run.processStartedAt ?? null,
|
|
4016
|
+
$exitCode: run.exitCode ?? null,
|
|
4017
|
+
$durationMs: run.durationMs ?? null,
|
|
4018
|
+
$stdout: scrubbedOrNull(run.stdout),
|
|
4019
|
+
$stderr: scrubbedOrNull(run.stderr),
|
|
4020
|
+
$error: scrubbedOrNull(run.error),
|
|
4021
|
+
$goalRunId: run.goalRunId ?? null,
|
|
4022
|
+
$created: run.createdAt,
|
|
4023
|
+
$updated: run.updatedAt
|
|
4024
|
+
});
|
|
4025
|
+
const imported = this.getRun(run.id);
|
|
4026
|
+
if (!imported)
|
|
4027
|
+
throw new Error(`run not found after migration import: ${run.id}`);
|
|
4028
|
+
return imported;
|
|
4029
|
+
}
|
|
3846
4030
|
pruneHistory(opts) {
|
|
3847
4031
|
const { maxAgeDays, keepPerLoop } = opts;
|
|
3848
4032
|
if (maxAgeDays === undefined && keepPerLoop === undefined) {
|
|
@@ -3984,7 +4168,7 @@ class Store {
|
|
|
3984
4168
|
// package.json
|
|
3985
4169
|
var package_default = {
|
|
3986
4170
|
name: "@hasna/loops",
|
|
3987
|
-
version: "0.4.
|
|
4171
|
+
version: "0.4.4",
|
|
3988
4172
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
3989
4173
|
type: "module",
|
|
3990
4174
|
main: "dist/index.js",
|
|
@@ -4058,7 +4242,7 @@ var package_default = {
|
|
|
4058
4242
|
prepare: "test -d dist || bun run build",
|
|
4059
4243
|
"dev:cli": "bun run src/cli/index.ts",
|
|
4060
4244
|
"dev:daemon": "bun run src/daemon/index.ts",
|
|
4061
|
-
prepublishOnly: "bun run build && bun run test:boundary"
|
|
4245
|
+
prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
|
|
4062
4246
|
},
|
|
4063
4247
|
keywords: [
|
|
4064
4248
|
"loops",
|
|
@@ -4251,7 +4435,7 @@ function deploymentStatusLine(status) {
|
|
|
4251
4435
|
|
|
4252
4436
|
// src/cli/index.ts
|
|
4253
4437
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
4254
|
-
import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, rmSync as rmSync7, statSync as statSync2 } from "fs";
|
|
4438
|
+
import { existsSync as existsSync10, readdirSync as readdirSync3, readFileSync as readFileSync10, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
4255
4439
|
import { join as join11 } from "path";
|
|
4256
4440
|
import { Database as Database3 } from "bun:sqlite";
|
|
4257
4441
|
import { Command } from "commander";
|
|
@@ -7984,6 +8168,515 @@ function runDoctor(store) {
|
|
|
7984
8168
|
};
|
|
7985
8169
|
}
|
|
7986
8170
|
|
|
8171
|
+
// src/lib/migration.ts
|
|
8172
|
+
import { createHash as createHash3 } from "crypto";
|
|
8173
|
+
import { hostname as hostname3 } from "os";
|
|
8174
|
+
var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
|
|
8175
|
+
function canonicalize(value) {
|
|
8176
|
+
if (Array.isArray(value))
|
|
8177
|
+
return value.map((entry) => canonicalize(entry));
|
|
8178
|
+
if (value && typeof value === "object") {
|
|
8179
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
|
|
8180
|
+
}
|
|
8181
|
+
return value;
|
|
8182
|
+
}
|
|
8183
|
+
function migrationHash(value) {
|
|
8184
|
+
return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
|
8185
|
+
}
|
|
8186
|
+
function pushBlocker(rows, resource, id, reason, name) {
|
|
8187
|
+
rows.push({ resource, id, name, action: "blocked", reason });
|
|
8188
|
+
}
|
|
8189
|
+
function checksToBlockers(checks, prefix, warnings = []) {
|
|
8190
|
+
const rows = [];
|
|
8191
|
+
for (const [name, count] of Object.entries(checks.unsupportedCounts)) {
|
|
8192
|
+
if (count > 0) {
|
|
8193
|
+
pushBlocker(rows, "remote", `${prefix}:unsupported:${name}`, `${prefix} ${name} has ${count} rows; this migration bundle does not preserve that table yet`);
|
|
8194
|
+
}
|
|
8195
|
+
}
|
|
8196
|
+
for (const [name, count] of Object.entries(checks.volatileCounts)) {
|
|
8197
|
+
if (name === "daemonLeases") {
|
|
8198
|
+
if (count > 0)
|
|
8199
|
+
warnings.push(`${prefix} daemon_lease has ${count} volatile rows; they are intentionally not exported as authority`);
|
|
8200
|
+
continue;
|
|
8201
|
+
}
|
|
8202
|
+
if (count > 0) {
|
|
8203
|
+
pushBlocker(rows, "remote", `${prefix}:volatile:${name}`, `${prefix} ${name} has ${count} rows; stop or finish active work before no-loss migration`);
|
|
8204
|
+
}
|
|
8205
|
+
}
|
|
8206
|
+
return rows;
|
|
8207
|
+
}
|
|
8208
|
+
function sanitizeCommandEnv(value, blockers, resource, id, name) {
|
|
8209
|
+
const copy = structuredClone(value);
|
|
8210
|
+
const target = copy.target;
|
|
8211
|
+
if (target && typeof target === "object" && !Array.isArray(target) && target.type === "command") {
|
|
8212
|
+
const commandTarget = target;
|
|
8213
|
+
if (commandTarget.env && Object.keys(commandTarget.env).length > 0) {
|
|
8214
|
+
commandTarget.env = Object.fromEntries(Object.keys(commandTarget.env).map((key) => [key, "[redacted]"]));
|
|
8215
|
+
pushBlocker(blockers, resource, id, "command target env values are redacted and cannot be imported as a no-loss row", name);
|
|
8216
|
+
}
|
|
8217
|
+
}
|
|
8218
|
+
return copy;
|
|
8219
|
+
}
|
|
8220
|
+
function sanitizeWorkflow(workflow, blockers) {
|
|
8221
|
+
const copy = structuredClone(workflow);
|
|
8222
|
+
copy.steps = copy.steps.map((step) => {
|
|
8223
|
+
if (step.target.type !== "command" || !step.target.env || Object.keys(step.target.env).length === 0)
|
|
8224
|
+
return step;
|
|
8225
|
+
pushBlocker(blockers, "workflow", workflow.id, `workflow step ${step.id} command env values are redacted and cannot be imported as a no-loss row`, workflow.name);
|
|
8226
|
+
return {
|
|
8227
|
+
...step,
|
|
8228
|
+
target: {
|
|
8229
|
+
...step.target,
|
|
8230
|
+
env: Object.fromEntries(Object.keys(step.target.env).map((key) => [key, "[redacted]"]))
|
|
8231
|
+
}
|
|
8232
|
+
};
|
|
8233
|
+
});
|
|
8234
|
+
return copy;
|
|
8235
|
+
}
|
|
8236
|
+
function sanitizeExportRows(rows) {
|
|
8237
|
+
const blockers = [];
|
|
8238
|
+
const warnings = [];
|
|
8239
|
+
blockers.push(...checksToBlockers(rows.checks, "source", warnings));
|
|
8240
|
+
const workflows = rows.workflows.map((workflow) => sanitizeWorkflow(workflow, blockers));
|
|
8241
|
+
const loops = rows.loops.map((loop) => sanitizeCommandEnv(loop, blockers, "loop", loop.id, loop.name));
|
|
8242
|
+
const beforeScrub = JSON.stringify({ workflows, loops, runs: rows.runs });
|
|
8243
|
+
const data = scrubSecretsDeep({ workflows, loops, runs: rows.runs });
|
|
8244
|
+
if (JSON.stringify(data) !== beforeScrub) {
|
|
8245
|
+
warnings.push("secret-looking strings were scrubbed from the export bundle; treat this as non-importable unless every scrub is expected");
|
|
8246
|
+
pushBlocker(blockers, "remote", "secret-scrub", "secret-looking strings were scrubbed from the export bundle");
|
|
8247
|
+
}
|
|
8248
|
+
return { data, blockers, warnings };
|
|
8249
|
+
}
|
|
8250
|
+
function exportLoopsMigrationBundle(store, opts = {}) {
|
|
8251
|
+
const rows = store.exportMigrationRows({ includeRuns: opts.includeRuns ?? true });
|
|
8252
|
+
const sanitized = sanitizeExportRows(rows);
|
|
8253
|
+
const bundleBody = {
|
|
8254
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8255
|
+
packageVersion: packageVersion(),
|
|
8256
|
+
exportedAt: new Date().toISOString(),
|
|
8257
|
+
source: {
|
|
8258
|
+
backend: "sqlite",
|
|
8259
|
+
schemaVersion: rows.schemaVersion,
|
|
8260
|
+
hostname: hostname3()
|
|
8261
|
+
},
|
|
8262
|
+
checks: rows.checks,
|
|
8263
|
+
importable: sanitized.blockers.length === 0,
|
|
8264
|
+
counts: {
|
|
8265
|
+
workflows: sanitized.data.workflows.length,
|
|
8266
|
+
loops: sanitized.data.loops.length,
|
|
8267
|
+
runs: sanitized.data.runs.length
|
|
8268
|
+
},
|
|
8269
|
+
data: sanitized.data,
|
|
8270
|
+
blockers: sanitized.blockers,
|
|
8271
|
+
warnings: sanitized.warnings
|
|
8272
|
+
};
|
|
8273
|
+
return {
|
|
8274
|
+
...bundleBody,
|
|
8275
|
+
hash: migrationHash(bundleBody)
|
|
8276
|
+
};
|
|
8277
|
+
}
|
|
8278
|
+
function validateLoopsMigrationBundle(value) {
|
|
8279
|
+
if (!value || typeof value !== "object")
|
|
8280
|
+
throw new ValidationError("migration bundle must be a JSON object");
|
|
8281
|
+
const bundle = value;
|
|
8282
|
+
if (bundle.schema !== LOOPS_MIGRATION_SCHEMA)
|
|
8283
|
+
throw new ValidationError(`unsupported migration bundle schema: ${String(bundle.schema)}`);
|
|
8284
|
+
if (!bundle.data || !Array.isArray(bundle.data.workflows) || !Array.isArray(bundle.data.loops) || !Array.isArray(bundle.data.runs)) {
|
|
8285
|
+
throw new ValidationError("migration bundle data must include workflows, loops, and runs arrays");
|
|
8286
|
+
}
|
|
8287
|
+
if (!bundle.checks || !bundle.counts || !bundle.source || !bundle.hash)
|
|
8288
|
+
throw new ValidationError("migration bundle is missing required metadata");
|
|
8289
|
+
const typed = bundle;
|
|
8290
|
+
assertMigrationBundleIntegrity(typed);
|
|
8291
|
+
return typed;
|
|
8292
|
+
}
|
|
8293
|
+
function assertMigrationBundleIntegrity(bundle) {
|
|
8294
|
+
const { hash: _hash, ...body } = bundle;
|
|
8295
|
+
const expectedHash = migrationHash(body);
|
|
8296
|
+
if (bundle.hash !== expectedHash)
|
|
8297
|
+
throw new ValidationError("migration bundle hash mismatch");
|
|
8298
|
+
}
|
|
8299
|
+
function rowPlanSummary(plan) {
|
|
8300
|
+
const count = (action) => plan.rows.filter((row) => row.action === action).length;
|
|
8301
|
+
return {
|
|
8302
|
+
dryRun: plan.dryRun,
|
|
8303
|
+
replace: plan.replace,
|
|
8304
|
+
importable: plan.importable,
|
|
8305
|
+
workflows: plan.rows.filter((row) => row.resource === "workflow").length,
|
|
8306
|
+
loops: plan.rows.filter((row) => row.resource === "loop").length,
|
|
8307
|
+
runs: plan.rows.filter((row) => row.resource === "run").length,
|
|
8308
|
+
insert: count("insert"),
|
|
8309
|
+
update: count("update"),
|
|
8310
|
+
skip: count("skip"),
|
|
8311
|
+
conflict: count("conflict"),
|
|
8312
|
+
blocked: count("blocked")
|
|
8313
|
+
};
|
|
8314
|
+
}
|
|
8315
|
+
function finalizePlan(plan) {
|
|
8316
|
+
return { ...plan, summary: rowPlanSummary(plan) };
|
|
8317
|
+
}
|
|
8318
|
+
function compareResource(current, incoming, opts) {
|
|
8319
|
+
const incomingHash = migrationHash(incoming);
|
|
8320
|
+
if (!current)
|
|
8321
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
|
|
8322
|
+
const currentHash = migrationHash(current);
|
|
8323
|
+
if (currentHash === incomingHash)
|
|
8324
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "skip", incomingHash, currentHash };
|
|
8325
|
+
return {
|
|
8326
|
+
resource: opts.resource,
|
|
8327
|
+
id: opts.id,
|
|
8328
|
+
name: opts.name,
|
|
8329
|
+
action: opts.replace ? "update" : "conflict",
|
|
8330
|
+
reason: opts.replace ? "existing row differs and --replace was requested" : "existing row differs; rerun with --replace to update",
|
|
8331
|
+
incomingHash,
|
|
8332
|
+
currentHash
|
|
8333
|
+
};
|
|
8334
|
+
}
|
|
8335
|
+
function buildImportMigrationPlan(store, bundle, opts = {}) {
|
|
8336
|
+
assertMigrationBundleIntegrity(bundle);
|
|
8337
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
8338
|
+
const replace = opts.replace ?? false;
|
|
8339
|
+
const rows = [];
|
|
8340
|
+
const warnings = [...bundle.warnings ?? []];
|
|
8341
|
+
rows.push(...checksToBlockers(store.exportMigrationRows({ includeRuns: false }).checks, "destination", warnings));
|
|
8342
|
+
if (!bundle.importable || bundle.blockers.length > 0) {
|
|
8343
|
+
rows.push(...bundle.blockers.map((row) => ({ ...row, action: "blocked" })));
|
|
8344
|
+
}
|
|
8345
|
+
const workflowIds = new Set(bundle.data.workflows.map((workflow) => workflow.id));
|
|
8346
|
+
const loopIds = new Set(bundle.data.loops.map((loop) => loop.id));
|
|
8347
|
+
for (const workflow of bundle.data.workflows) {
|
|
8348
|
+
const redactedStep = workflow.steps.find((step) => step.target.type === "command" && step.target.env && Object.values(step.target.env).includes("[redacted]"));
|
|
8349
|
+
if (redactedStep) {
|
|
8350
|
+
rows.push({
|
|
8351
|
+
resource: "workflow",
|
|
8352
|
+
id: workflow.id,
|
|
8353
|
+
name: workflow.name,
|
|
8354
|
+
action: "blocked",
|
|
8355
|
+
reason: `workflow step ${redactedStep.id} has redacted command env values`,
|
|
8356
|
+
incomingHash: migrationHash(workflow)
|
|
8357
|
+
});
|
|
8358
|
+
continue;
|
|
8359
|
+
}
|
|
8360
|
+
const activeNameCollision = store.listWorkflows({ status: "active" }).find((current) => current.name === workflow.name && current.id !== workflow.id);
|
|
8361
|
+
if (workflow.status === "active" && activeNameCollision) {
|
|
8362
|
+
rows.push({
|
|
8363
|
+
resource: "workflow",
|
|
8364
|
+
id: workflow.id,
|
|
8365
|
+
name: workflow.name,
|
|
8366
|
+
action: "conflict",
|
|
8367
|
+
reason: `active workflow name collides with existing workflow ${activeNameCollision.id}`,
|
|
8368
|
+
incomingHash: migrationHash(workflow),
|
|
8369
|
+
currentHash: migrationHash(activeNameCollision)
|
|
8370
|
+
});
|
|
8371
|
+
continue;
|
|
8372
|
+
}
|
|
8373
|
+
rows.push(compareResource(store.getWorkflow(workflow.id), workflow, { replace, resource: "workflow", id: workflow.id, name: workflow.name }));
|
|
8374
|
+
}
|
|
8375
|
+
for (const loop of bundle.data.loops) {
|
|
8376
|
+
if (loop.target.type === "command" && loop.target.env && Object.values(loop.target.env).includes("[redacted]")) {
|
|
8377
|
+
rows.push({
|
|
8378
|
+
resource: "loop",
|
|
8379
|
+
id: loop.id,
|
|
8380
|
+
name: loop.name,
|
|
8381
|
+
action: "blocked",
|
|
8382
|
+
reason: "loop has redacted command env values",
|
|
8383
|
+
incomingHash: migrationHash(loop)
|
|
8384
|
+
});
|
|
8385
|
+
continue;
|
|
8386
|
+
}
|
|
8387
|
+
if (loop.target.type === "workflow" && !workflowIds.has(loop.target.workflowId) && !store.getWorkflow(loop.target.workflowId)) {
|
|
8388
|
+
rows.push({
|
|
8389
|
+
resource: "loop",
|
|
8390
|
+
id: loop.id,
|
|
8391
|
+
name: loop.name,
|
|
8392
|
+
action: "blocked",
|
|
8393
|
+
reason: `workflow target ${loop.target.workflowId} is not present in bundle or destination store`,
|
|
8394
|
+
incomingHash: migrationHash(loop)
|
|
8395
|
+
});
|
|
8396
|
+
continue;
|
|
8397
|
+
}
|
|
8398
|
+
rows.push(compareResource(store.getLoop(loop.id), loop, { replace, resource: "loop", id: loop.id, name: loop.name }));
|
|
8399
|
+
}
|
|
8400
|
+
if (includeRuns) {
|
|
8401
|
+
for (const run of bundle.data.runs) {
|
|
8402
|
+
if (run.status === "running") {
|
|
8403
|
+
rows.push({
|
|
8404
|
+
resource: "run",
|
|
8405
|
+
id: run.id,
|
|
8406
|
+
name: run.loopName,
|
|
8407
|
+
action: "blocked",
|
|
8408
|
+
reason: "running rows carry volatile lease/process ownership and must finish before import",
|
|
8409
|
+
incomingHash: migrationHash(run)
|
|
8410
|
+
});
|
|
8411
|
+
continue;
|
|
8412
|
+
}
|
|
8413
|
+
if (!loopIds.has(run.loopId) && !store.getLoop(run.loopId)) {
|
|
8414
|
+
rows.push({
|
|
8415
|
+
resource: "run",
|
|
8416
|
+
id: run.id,
|
|
8417
|
+
name: run.loopName,
|
|
8418
|
+
action: "blocked",
|
|
8419
|
+
reason: `loop ${run.loopId} is not present in bundle or destination store`,
|
|
8420
|
+
incomingHash: migrationHash(run)
|
|
8421
|
+
});
|
|
8422
|
+
continue;
|
|
8423
|
+
}
|
|
8424
|
+
const slot = store.getRunBySlot(run.loopId, run.scheduledFor);
|
|
8425
|
+
if (slot && slot.id !== run.id) {
|
|
8426
|
+
rows.push({
|
|
8427
|
+
resource: "run",
|
|
8428
|
+
id: run.id,
|
|
8429
|
+
name: run.loopName,
|
|
8430
|
+
action: "conflict",
|
|
8431
|
+
reason: `scheduled slot already belongs to run ${slot.id}`,
|
|
8432
|
+
incomingHash: migrationHash(run),
|
|
8433
|
+
currentHash: migrationHash(slot)
|
|
8434
|
+
});
|
|
8435
|
+
continue;
|
|
8436
|
+
}
|
|
8437
|
+
rows.push(compareResource(store.getRun(run.id), run, { replace, resource: "run", id: run.id, name: run.loopName }));
|
|
8438
|
+
}
|
|
8439
|
+
} else if (bundle.data.runs.length > 0) {
|
|
8440
|
+
warnings.push("run history is present in the bundle but --no-runs was requested");
|
|
8441
|
+
}
|
|
8442
|
+
const plan = finalizePlan({
|
|
8443
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8444
|
+
operation: "import",
|
|
8445
|
+
dryRun: opts.dryRun ?? true,
|
|
8446
|
+
replace,
|
|
8447
|
+
importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
|
|
8448
|
+
rows,
|
|
8449
|
+
warnings
|
|
8450
|
+
});
|
|
8451
|
+
return { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
|
|
8452
|
+
}
|
|
8453
|
+
function applyImportMigrationBundle(store, bundle, opts = {}) {
|
|
8454
|
+
const plan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
8455
|
+
if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
|
|
8456
|
+
throw new ValidationError(`migration import is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
|
|
8457
|
+
}
|
|
8458
|
+
const applied = { workflows: 0, loops: 0, runs: 0 };
|
|
8459
|
+
let appliedPlan = plan;
|
|
8460
|
+
store.writeTransaction(() => {
|
|
8461
|
+
appliedPlan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
8462
|
+
if (appliedPlan.summary.blocked > 0 || appliedPlan.summary.conflict > 0 || !appliedPlan.importable) {
|
|
8463
|
+
throw new ValidationError(`destination store changed before import apply: blocked=${appliedPlan.summary.blocked} conflict=${appliedPlan.summary.conflict}`);
|
|
8464
|
+
}
|
|
8465
|
+
for (const workflow of bundle.data.workflows) {
|
|
8466
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "workflow" && entry.id === workflow.id);
|
|
8467
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8468
|
+
store.upsertMigrationWorkflow(workflow, { replace: opts.replace });
|
|
8469
|
+
applied.workflows += 1;
|
|
8470
|
+
}
|
|
8471
|
+
}
|
|
8472
|
+
for (const loop of bundle.data.loops) {
|
|
8473
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "loop" && entry.id === loop.id);
|
|
8474
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8475
|
+
store.upsertMigrationLoop(loop, { replace: opts.replace });
|
|
8476
|
+
applied.loops += 1;
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
if (opts.includeRuns ?? true) {
|
|
8480
|
+
for (const run of bundle.data.runs) {
|
|
8481
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "run" && entry.id === run.id);
|
|
8482
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8483
|
+
store.upsertMigrationRun(run, { replace: opts.replace });
|
|
8484
|
+
applied.runs += 1;
|
|
8485
|
+
}
|
|
8486
|
+
}
|
|
8487
|
+
}
|
|
8488
|
+
});
|
|
8489
|
+
return { plan: appliedPlan, applied };
|
|
8490
|
+
}
|
|
8491
|
+
function envValue2(env, keys) {
|
|
8492
|
+
for (const key of keys) {
|
|
8493
|
+
const value = env[key]?.trim();
|
|
8494
|
+
if (value)
|
|
8495
|
+
return value;
|
|
8496
|
+
}
|
|
8497
|
+
return;
|
|
8498
|
+
}
|
|
8499
|
+
function resolveApiConfig(opts) {
|
|
8500
|
+
const env = opts.env ?? process.env;
|
|
8501
|
+
return {
|
|
8502
|
+
apiUrl: opts.apiUrl ?? envValue2(env, ["LOOPS_API_URL", "HASNA_LOOPS_API_URL", "LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"]),
|
|
8503
|
+
token: opts.apiToken ?? envValue2(env, ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN", "LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"])
|
|
8504
|
+
};
|
|
8505
|
+
}
|
|
8506
|
+
function isLocalApiUrl(value) {
|
|
8507
|
+
try {
|
|
8508
|
+
return ["127.0.0.1", "localhost", "::1"].includes(new URL(value).hostname);
|
|
8509
|
+
} catch {
|
|
8510
|
+
return false;
|
|
8511
|
+
}
|
|
8512
|
+
}
|
|
8513
|
+
function endpoint(base, path) {
|
|
8514
|
+
return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
|
|
8515
|
+
}
|
|
8516
|
+
async function requestJson(fetchImpl, config, path, init = {}) {
|
|
8517
|
+
const response = await fetchImpl(endpoint(config.apiUrl, path), {
|
|
8518
|
+
...init,
|
|
8519
|
+
headers: {
|
|
8520
|
+
...init.body ? { "content-type": "application/json" } : {},
|
|
8521
|
+
...config.token ? { authorization: `Bearer ${config.token}` } : {},
|
|
8522
|
+
...init.headers
|
|
8523
|
+
}
|
|
8524
|
+
});
|
|
8525
|
+
const payload = await response.json().catch(() => ({}));
|
|
8526
|
+
if (!response.ok)
|
|
8527
|
+
throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
|
|
8528
|
+
return payload;
|
|
8529
|
+
}
|
|
8530
|
+
async function fetchRemotePreview(opts) {
|
|
8531
|
+
const config = resolveApiConfig(opts);
|
|
8532
|
+
const warnings = [];
|
|
8533
|
+
if (!config.apiUrl) {
|
|
8534
|
+
warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
|
|
8535
|
+
return { loops: [], runs: [], warnings };
|
|
8536
|
+
}
|
|
8537
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token) {
|
|
8538
|
+
warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
8539
|
+
return { loops: [], runs: [], warnings };
|
|
8540
|
+
}
|
|
8541
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
8542
|
+
const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
|
|
8543
|
+
const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
|
|
8544
|
+
return {
|
|
8545
|
+
loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
|
|
8546
|
+
runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
|
|
8547
|
+
warnings
|
|
8548
|
+
};
|
|
8549
|
+
}
|
|
8550
|
+
async function buildSelfHostedMigrationPlan(store, opts) {
|
|
8551
|
+
const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
|
|
8552
|
+
const remote = await fetchRemotePreview(opts);
|
|
8553
|
+
const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
|
|
8554
|
+
const warnings = [
|
|
8555
|
+
...bundle.warnings,
|
|
8556
|
+
...remote.warnings,
|
|
8557
|
+
"self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
|
|
8558
|
+
];
|
|
8559
|
+
if (opts.operation === "self-hosted-pull") {
|
|
8560
|
+
for (const entry of remote.loops) {
|
|
8561
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
8562
|
+
const id = typeof value.id === "string" ? value.id : `remote-loop:${rows.length}`;
|
|
8563
|
+
rows.push({
|
|
8564
|
+
resource: "loop",
|
|
8565
|
+
id,
|
|
8566
|
+
name: typeof value.name === "string" ? value.name : undefined,
|
|
8567
|
+
action: "blocked",
|
|
8568
|
+
reason: "remote loop pull needs a full id-preserving export/import endpoint; /v1/loops returns public redacted rows only",
|
|
8569
|
+
currentHash: migrationHash(entry)
|
|
8570
|
+
});
|
|
8571
|
+
}
|
|
8572
|
+
for (const entry of remote.runs) {
|
|
8573
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
8574
|
+
const id = typeof value.id === "string" ? value.id : `remote-run:${rows.length}`;
|
|
8575
|
+
rows.push({
|
|
8576
|
+
resource: "run",
|
|
8577
|
+
id,
|
|
8578
|
+
name: typeof value.loopName === "string" ? value.loopName : undefined,
|
|
8579
|
+
action: "blocked",
|
|
8580
|
+
reason: "remote run-history pull needs an id-preserving export/import endpoint; /v1/runs returns public redacted rows only",
|
|
8581
|
+
currentHash: migrationHash(entry)
|
|
8582
|
+
});
|
|
8583
|
+
}
|
|
8584
|
+
const plan2 = finalizePlan({
|
|
8585
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8586
|
+
operation: opts.operation,
|
|
8587
|
+
dryRun: true,
|
|
8588
|
+
replace: false,
|
|
8589
|
+
importable: false,
|
|
8590
|
+
rows,
|
|
8591
|
+
warnings
|
|
8592
|
+
});
|
|
8593
|
+
return { ...plan2, importable: false };
|
|
8594
|
+
}
|
|
8595
|
+
const remoteLoopsByName = new Map(remote.loops.map((entry) => entry && typeof entry === "object" ? entry : undefined).filter((entry) => typeof entry?.name === "string").map((entry) => [String(entry.name), entry]));
|
|
8596
|
+
for (const workflow of bundle.data.workflows) {
|
|
8597
|
+
rows.push({
|
|
8598
|
+
resource: "workflow",
|
|
8599
|
+
id: workflow.id,
|
|
8600
|
+
name: workflow.name,
|
|
8601
|
+
action: "blocked",
|
|
8602
|
+
reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
|
|
8603
|
+
incomingHash: migrationHash(workflow)
|
|
8604
|
+
});
|
|
8605
|
+
}
|
|
8606
|
+
for (const loop of bundle.data.loops) {
|
|
8607
|
+
const remoteLoop = remoteLoopsByName.get(loop.name);
|
|
8608
|
+
rows.push({
|
|
8609
|
+
resource: "loop",
|
|
8610
|
+
id: loop.id,
|
|
8611
|
+
name: loop.name,
|
|
8612
|
+
action: "blocked",
|
|
8613
|
+
reason: remoteLoop ? `remote loop name exists (${String(remoteLoop.id ?? "unknown id")}); exact id-preserving comparison is not available` : "normal self-hosted loop create would generate a new id; waiting for id-preserving import endpoint",
|
|
8614
|
+
incomingHash: migrationHash(loop),
|
|
8615
|
+
currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
|
|
8616
|
+
});
|
|
8617
|
+
}
|
|
8618
|
+
if (opts.includeRuns ?? true) {
|
|
8619
|
+
for (const run of bundle.data.runs) {
|
|
8620
|
+
rows.push({
|
|
8621
|
+
resource: "run",
|
|
8622
|
+
id: run.id,
|
|
8623
|
+
name: run.loopName,
|
|
8624
|
+
action: "blocked",
|
|
8625
|
+
reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
|
|
8626
|
+
incomingHash: migrationHash(run)
|
|
8627
|
+
});
|
|
8628
|
+
}
|
|
8629
|
+
}
|
|
8630
|
+
const plan = finalizePlan({
|
|
8631
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8632
|
+
operation: opts.operation,
|
|
8633
|
+
dryRun: true,
|
|
8634
|
+
replace: false,
|
|
8635
|
+
importable: false,
|
|
8636
|
+
rows,
|
|
8637
|
+
warnings
|
|
8638
|
+
});
|
|
8639
|
+
return { ...plan, importable: false };
|
|
8640
|
+
}
|
|
8641
|
+
async function registerSelfHostedRunner(opts) {
|
|
8642
|
+
const config = resolveApiConfig(opts);
|
|
8643
|
+
if (!config.apiUrl)
|
|
8644
|
+
throw new ValidationError("LOOPS_API_URL or --api-url is required");
|
|
8645
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token)
|
|
8646
|
+
throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
8647
|
+
const payload = await requestJson(opts.fetchImpl ?? fetch, { apiUrl: config.apiUrl, token: config.token }, "/v1/runners/register", {
|
|
8648
|
+
method: "POST",
|
|
8649
|
+
body: JSON.stringify({
|
|
8650
|
+
runnerId: opts.runnerId,
|
|
8651
|
+
machineId: opts.machineId,
|
|
8652
|
+
labels: opts.labels ?? {},
|
|
8653
|
+
capabilities: opts.capabilities ?? {}
|
|
8654
|
+
})
|
|
8655
|
+
});
|
|
8656
|
+
return {
|
|
8657
|
+
ok: payload.ok === true,
|
|
8658
|
+
runner: payload.runner && typeof payload.runner === "object" ? payload.runner : {}
|
|
8659
|
+
};
|
|
8660
|
+
}
|
|
8661
|
+
function publicMigrationBundle(bundle) {
|
|
8662
|
+
return {
|
|
8663
|
+
...bundle,
|
|
8664
|
+
data: {
|
|
8665
|
+
workflows: bundle.data.workflows.map(publicWorkflow),
|
|
8666
|
+
loops: bundle.data.loops.map(publicLoop),
|
|
8667
|
+
runs: bundle.data.runs.map((run) => publicRun(run, false, { redactError: true }))
|
|
8668
|
+
}
|
|
8669
|
+
};
|
|
8670
|
+
}
|
|
8671
|
+
function selfHostedControlPlaneSummary(env = process.env) {
|
|
8672
|
+
const config = loopControlPlaneConfig(env);
|
|
8673
|
+
return {
|
|
8674
|
+
apiUrl: config.apiUrl,
|
|
8675
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
8676
|
+
authTokenPresent: config.apiAuthTokenPresent
|
|
8677
|
+
};
|
|
8678
|
+
}
|
|
8679
|
+
|
|
7987
8680
|
// src/lib/hygiene.ts
|
|
7988
8681
|
import { basename as basename3 } from "path";
|
|
7989
8682
|
import { homedir as homedir3 } from "os";
|
|
@@ -8321,7 +9014,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8321
9014
|
{ name: "taskTitle", description: "Human-readable task title." },
|
|
8322
9015
|
projectPathVariable(),
|
|
8323
9016
|
{ name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
|
|
8324
|
-
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
8325
9017
|
...routeScopeVariables(),
|
|
8326
9018
|
...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
|
|
8327
9019
|
]
|
|
@@ -8362,8 +9054,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
8362
9054
|
variables: [
|
|
8363
9055
|
{ name: "taskId", required: true, description: "Todos task id." },
|
|
8364
9056
|
projectPathVariable(),
|
|
8365
|
-
{ name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
|
|
8366
|
-
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
8367
9057
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
8368
9058
|
roleAuthProfileVariable("triage"),
|
|
8369
9059
|
roleAuthProfileVariable("planner"),
|
|
@@ -8494,29 +9184,28 @@ function verifierRuntimeGuidance(input) {
|
|
|
8494
9184
|
].join(`
|
|
8495
9185
|
`);
|
|
8496
9186
|
}
|
|
8497
|
-
function todosInspectLine(todosProjectPath, taskId
|
|
8498
|
-
return `- Inspect first: ${
|
|
9187
|
+
function todosInspectLine(todosProjectPath, taskId) {
|
|
9188
|
+
return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
|
|
8499
9189
|
}
|
|
8500
|
-
function todosStartLine(todosProjectPath, taskId
|
|
8501
|
-
return `- Claim/start if appropriate: ${
|
|
9190
|
+
function todosStartLine(todosProjectPath, taskId) {
|
|
9191
|
+
return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
|
|
8502
9192
|
}
|
|
8503
|
-
function todosEvidenceLine(todosProjectPath, taskId, placeholder
|
|
8504
|
-
return `- Record evidence: ${
|
|
9193
|
+
function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
|
|
9194
|
+
return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
|
|
8505
9195
|
}
|
|
8506
|
-
function todosVerificationLine(todosProjectPath, taskId
|
|
8507
|
-
return `- Record verification: ${
|
|
9196
|
+
function todosVerificationLine(todosProjectPath, taskId) {
|
|
9197
|
+
return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
|
|
8508
9198
|
}
|
|
8509
|
-
function todosDoneLine(todosProjectPath, taskId
|
|
8510
|
-
return `- If valid and complete: ${
|
|
9199
|
+
function todosDoneLine(todosProjectPath, taskId) {
|
|
9200
|
+
return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
|
|
8511
9201
|
}
|
|
8512
|
-
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines
|
|
9202
|
+
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
|
|
8513
9203
|
return [
|
|
8514
9204
|
`Todos project path: ${todosProjectPath}`,
|
|
8515
|
-
todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
|
|
8516
9205
|
"Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
|
|
8517
|
-
todosInspectLine(todosProjectPath, taskId
|
|
9206
|
+
todosInspectLine(todosProjectPath, taskId),
|
|
8518
9207
|
...commandLines
|
|
8519
|
-
]
|
|
9208
|
+
];
|
|
8520
9209
|
}
|
|
8521
9210
|
function worktreePrompt(plan) {
|
|
8522
9211
|
if (plan.enabled) {
|
|
@@ -8553,19 +9242,10 @@ function worktreeContextFragment(plan) {
|
|
|
8553
9242
|
function shellQuote3(value) {
|
|
8554
9243
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8555
9244
|
}
|
|
8556
|
-
function
|
|
8557
|
-
return todosDbPath ? `TODOS_DB_PATH=${shellQuote3(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote3(todosDbPath)} ` : "";
|
|
8558
|
-
}
|
|
8559
|
-
function todosCommand(todosProjectPath, todosDbPath) {
|
|
8560
|
-
return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
|
|
8561
|
-
}
|
|
8562
|
-
function shellTodosCommand(todosProjectPath, todosDbPath) {
|
|
8563
|
-
return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote3(todosProjectPath)}`;
|
|
8564
|
-
}
|
|
8565
|
-
function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
|
|
9245
|
+
function sourceTaskGateCommand(todosProjectPath, taskId) {
|
|
8566
9246
|
return [
|
|
8567
9247
|
"set -euo pipefail",
|
|
8568
|
-
|
|
9248
|
+
`todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)} >/dev/null`,
|
|
8569
9249
|
`printf "source task %s resolved in todos project %s\\n" ${shellQuote3(taskId)} ${shellQuote3(todosProjectPath)}`
|
|
8570
9250
|
].join(`
|
|
8571
9251
|
`);
|
|
@@ -8625,10 +9305,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
|
|
|
8625
9305
|
"console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
|
|
8626
9306
|
].join(`
|
|
8627
9307
|
`);
|
|
8628
|
-
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker
|
|
9308
|
+
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
|
|
8629
9309
|
return [
|
|
8630
9310
|
"set -euo pipefail",
|
|
8631
|
-
`task_json="$(${
|
|
9311
|
+
`task_json="$(todos --project ${shellQuote3(todosProjectPath)} --json inspect ${shellQuote3(taskId)})"`,
|
|
8632
9312
|
`TASK_JSON="$task_json" STAGE=${shellQuote3(stage)} bun - <<'BUN'`,
|
|
8633
9313
|
LIFECYCLE_GATE_SCRIPT_HEAD,
|
|
8634
9314
|
`const goMarker = ${JSON.stringify(goMarker)};`,
|
|
@@ -8644,7 +9324,6 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8644
9324
|
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
8645
9325
|
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
8646
9326
|
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
8647
|
-
"const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
|
|
8648
9327
|
"const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
8649
9328
|
"const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
|
|
8650
9329
|
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
@@ -8662,8 +9341,7 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
8662
9341
|
"};",
|
|
8663
9342
|
"const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
|
|
8664
9343
|
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
8665
|
-
"const
|
|
8666
|
-
"const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
|
|
9344
|
+
"const todos = (...args) => run(todosBin, todosArgs(...args));",
|
|
8667
9345
|
"const comment = (text) => {",
|
|
8668
9346
|
" const result = todos('comment', taskId, text);",
|
|
8669
9347
|
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
@@ -8792,7 +9470,6 @@ function prHandoffCommand(opts) {
|
|
|
8792
9470
|
`export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote3(opts.artifactPath)}`,
|
|
8793
9471
|
`export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote3(opts.taskId)}`,
|
|
8794
9472
|
`export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote3(opts.todosProjectPath)}`,
|
|
8795
|
-
opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote3(opts.todosDbPath)}` : undefined,
|
|
8796
9473
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote3(opts.worktreeCwd)}`,
|
|
8797
9474
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote3(opts.worktreeRoot)}`,
|
|
8798
9475
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote3(opts.expectedBranch)}`,
|
|
@@ -8803,7 +9480,7 @@ function prHandoffCommand(opts) {
|
|
|
8803
9480
|
"bun - <<'BUN'",
|
|
8804
9481
|
PR_HANDOFF_SCRIPT,
|
|
8805
9482
|
"BUN"
|
|
8806
|
-
].
|
|
9483
|
+
].join(`
|
|
8807
9484
|
`);
|
|
8808
9485
|
}
|
|
8809
9486
|
// src/lib/templates-custom.ts
|
|
@@ -9393,12 +10070,12 @@ function commandStep(opts) {
|
|
|
9393
10070
|
...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
|
|
9394
10071
|
};
|
|
9395
10072
|
}
|
|
9396
|
-
function sourceTaskGateStep(todosProjectPath, taskId, plan, description
|
|
10073
|
+
function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
|
|
9397
10074
|
return commandStep({
|
|
9398
10075
|
id: "source-task-gate",
|
|
9399
10076
|
name: "Source Task Gate",
|
|
9400
10077
|
description,
|
|
9401
|
-
command: sourceTaskGateCommand(todosProjectPath, taskId
|
|
10078
|
+
command: sourceTaskGateCommand(todosProjectPath, taskId),
|
|
9402
10079
|
cwd: plan.originalCwd,
|
|
9403
10080
|
timeoutMs: 60000,
|
|
9404
10081
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9410,7 +10087,7 @@ function lifecycleGateStep(opts) {
|
|
|
9410
10087
|
name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
|
|
9411
10088
|
description: opts.description,
|
|
9412
10089
|
dependsOn: opts.dependsOn,
|
|
9413
|
-
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker
|
|
10090
|
+
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
|
|
9414
10091
|
cwd: opts.plan.originalCwd,
|
|
9415
10092
|
timeoutMs: 2 * 60000,
|
|
9416
10093
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -9419,7 +10096,7 @@ function lifecycleGateStep(opts) {
|
|
|
9419
10096
|
function prHandoffArtifactPath(plan, taskId) {
|
|
9420
10097
|
return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
|
|
9421
10098
|
}
|
|
9422
|
-
function prHandoffStep(input, plan, todosProjectPath
|
|
10099
|
+
function prHandoffStep(input, plan, todosProjectPath) {
|
|
9423
10100
|
return commandStep({
|
|
9424
10101
|
id: "pr-handoff",
|
|
9425
10102
|
name: "PR Handoff",
|
|
@@ -9429,7 +10106,6 @@ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
|
|
|
9429
10106
|
artifactPath: prHandoffArtifactPath(plan, input.taskId),
|
|
9430
10107
|
taskId: input.taskId,
|
|
9431
10108
|
todosProjectPath,
|
|
9432
|
-
todosDbPath,
|
|
9433
10109
|
worktreeCwd: plan.cwd,
|
|
9434
10110
|
worktreeRoot: plan.path ?? plan.cwd,
|
|
9435
10111
|
expectedBranch: plan.branch ?? ""
|
|
@@ -9516,7 +10192,6 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9516
10192
|
if (!input.projectPath?.trim())
|
|
9517
10193
|
throw new Error("projectPath is required");
|
|
9518
10194
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9519
|
-
const todosDbPath = input.todosDbPath;
|
|
9520
10195
|
const plan = worktreePlan(input, input.taskId);
|
|
9521
10196
|
const taskContext = {
|
|
9522
10197
|
taskId: input.taskId,
|
|
@@ -9527,17 +10202,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9527
10202
|
projectPath: input.projectPath,
|
|
9528
10203
|
routeProjectPath: input.routeProjectPath,
|
|
9529
10204
|
projectGroup: input.projectGroup,
|
|
9530
|
-
todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
|
|
9531
|
-
todosDbPath,
|
|
9532
10205
|
worktree: worktreeContextFragment(plan)
|
|
9533
10206
|
};
|
|
9534
10207
|
const workerPrompt = [
|
|
9535
10208
|
...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
|
|
9536
10209
|
worktreePrompt(plan),
|
|
9537
10210
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9538
|
-
todosStartLine(todosProjectPath, input.taskId
|
|
9539
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers"
|
|
9540
|
-
]
|
|
10211
|
+
todosStartLine(todosProjectPath, input.taskId),
|
|
10212
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
|
|
10213
|
+
]),
|
|
9541
10214
|
"Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
|
|
9542
10215
|
"Inspect the repository/project state, implement only the task scope, run focused validation, preserve unrelated user changes, and update the task with comments, evidence, changed files, commits, and blockers.",
|
|
9543
10216
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
@@ -9550,9 +10223,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9550
10223
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
|
|
9551
10224
|
worktreePrompt(plan),
|
|
9552
10225
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9553
|
-
todosVerificationLine(todosProjectPath, input.taskId
|
|
9554
|
-
todosDoneLine(todosProjectPath, input.taskId
|
|
9555
|
-
]
|
|
10226
|
+
todosVerificationLine(todosProjectPath, input.taskId),
|
|
10227
|
+
todosDoneLine(todosProjectPath, input.taskId)
|
|
10228
|
+
]),
|
|
9556
10229
|
adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
|
|
9557
10230
|
verifierRuntimeGuidance(input),
|
|
9558
10231
|
TASK_VERIFIER_DECISION_FRAGMENT,
|
|
@@ -9567,7 +10240,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
9567
10240
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
9568
10241
|
version: 1,
|
|
9569
10242
|
steps: [
|
|
9570
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."
|
|
10243
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
|
|
9571
10244
|
...workerVerifierSteps({
|
|
9572
10245
|
input,
|
|
9573
10246
|
seed: input.taskId,
|
|
@@ -9587,7 +10260,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9587
10260
|
if (!input.projectPath?.trim())
|
|
9588
10261
|
throw new Error("projectPath is required");
|
|
9589
10262
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
9590
|
-
const todosDbPath = input.todosDbPath;
|
|
9591
10263
|
const plan = worktreePlan(input, input.taskId);
|
|
9592
10264
|
const taskContext = {
|
|
9593
10265
|
taskId: input.taskId,
|
|
@@ -9599,7 +10271,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9599
10271
|
routeProjectPath: input.routeProjectPath,
|
|
9600
10272
|
projectGroup: input.projectGroup,
|
|
9601
10273
|
todosProjectPath,
|
|
9602
|
-
todosDbPath,
|
|
9603
10274
|
worktree: worktreeContextFragment(plan)
|
|
9604
10275
|
};
|
|
9605
10276
|
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
@@ -9613,8 +10284,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9613
10284
|
const shared = [
|
|
9614
10285
|
worktreePrompt(plan),
|
|
9615
10286
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
9616
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker"
|
|
9617
|
-
]
|
|
10287
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
|
|
10288
|
+
]),
|
|
9618
10289
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
9619
10290
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
9620
10291
|
"",
|
|
@@ -9623,7 +10294,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9623
10294
|
].join(`
|
|
9624
10295
|
`);
|
|
9625
10296
|
const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
|
|
9626
|
-
const blockTaskCommand =
|
|
10297
|
+
const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
|
|
9627
10298
|
const gateStopFragment = (stage, stops) => `The deterministic ${stage} gate will stop ${stops} unless the latest ${stage} marker is the exact go marker and the task has no blocked/completed/done/cancelled/failed/archived/no-auto/manual/approval-required state.`;
|
|
9628
10299
|
const triagePrompt = [
|
|
9629
10300
|
...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
|
|
@@ -9651,7 +10322,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9651
10322
|
const workerPrompt = [
|
|
9652
10323
|
...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
|
|
9653
10324
|
shared,
|
|
9654
|
-
todosStartLine(todosProjectPath, input.taskId
|
|
10325
|
+
todosStartLine(todosProjectPath, input.taskId),
|
|
9655
10326
|
"Read the triage and planner comments first. Implement only the scoped task, run focused validation, and record changed files, commits, evidence, blockers, and residual risks.",
|
|
9656
10327
|
input.prHandoff ? `When only GitHub network access is blocked after a successful commit/validation, record the handoff artifact at ${handoffArtifactPath} instead of repeatedly retrying push/PR creation.` : undefined,
|
|
9657
10328
|
WORKER_LEAVES_COMPLETION_FRAGMENT
|
|
@@ -9660,8 +10331,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9660
10331
|
const verifierPrompt = [
|
|
9661
10332
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
|
|
9662
10333
|
shared,
|
|
9663
|
-
todosVerificationLine(todosProjectPath, input.taskId
|
|
9664
|
-
todosDoneLine(todosProjectPath, input.taskId
|
|
10334
|
+
todosVerificationLine(todosProjectPath, input.taskId),
|
|
10335
|
+
todosDoneLine(todosProjectPath, input.taskId),
|
|
9665
10336
|
adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
|
|
9666
10337
|
verifierRuntimeGuidance(input),
|
|
9667
10338
|
input.prHandoff ? `If ${handoffArtifactPath} exists and there is no PR URL evidence, verify that the PR handoff step queued or completed a bounded handoff; leave the original task open or blocked until PR evidence is recorded.` : undefined,
|
|
@@ -9670,7 +10341,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9670
10341
|
].filter(Boolean).join(`
|
|
9671
10342
|
`);
|
|
9672
10343
|
const steps = [
|
|
9673
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."
|
|
10344
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
|
|
9674
10345
|
{
|
|
9675
10346
|
id: "triage",
|
|
9676
10347
|
name: "Triage",
|
|
@@ -9684,7 +10355,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9684
10355
|
description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
|
|
9685
10356
|
dependsOn: ["triage"],
|
|
9686
10357
|
todosProjectPath,
|
|
9687
|
-
todosDbPath,
|
|
9688
10358
|
taskId: input.taskId,
|
|
9689
10359
|
goMarker: gateMarker("triage", "go"),
|
|
9690
10360
|
blockedMarker: gateMarker("triage", "blocked"),
|
|
@@ -9703,7 +10373,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9703
10373
|
description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
|
|
9704
10374
|
dependsOn: ["planner"],
|
|
9705
10375
|
todosProjectPath,
|
|
9706
|
-
todosDbPath,
|
|
9707
10376
|
taskId: input.taskId,
|
|
9708
10377
|
goMarker: gateMarker("planner", "go"),
|
|
9709
10378
|
blockedMarker: gateMarker("planner", "blocked"),
|
|
@@ -9719,7 +10388,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
9719
10388
|
}
|
|
9720
10389
|
];
|
|
9721
10390
|
if (input.prHandoff) {
|
|
9722
|
-
steps.push(prHandoffStep(input, plan, todosProjectPath
|
|
10391
|
+
steps.push(prHandoffStep(input, plan, todosProjectPath));
|
|
9723
10392
|
}
|
|
9724
10393
|
steps.push({
|
|
9725
10394
|
id: "verifier",
|
|
@@ -9944,7 +10613,6 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
9944
10613
|
taskTitle: values.taskTitle,
|
|
9945
10614
|
taskDescription: values.taskDescription,
|
|
9946
10615
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
9947
|
-
todosDbPath: values.todosDbPath,
|
|
9948
10616
|
triageAuthProfile: values.triageAuthProfile,
|
|
9949
10617
|
plannerAuthProfile: values.plannerAuthProfile,
|
|
9950
10618
|
triageAccount: accountVar(values.triageAccount, values.accountTool),
|
|
@@ -10009,7 +10677,6 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
10009
10677
|
taskTitle: values.taskTitle,
|
|
10010
10678
|
taskDescription: values.taskDescription,
|
|
10011
10679
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
10012
|
-
todosDbPath: values.todosDbPath,
|
|
10013
10680
|
eventId: values.eventId,
|
|
10014
10681
|
eventType: values.eventType
|
|
10015
10682
|
});
|
|
@@ -10169,7 +10836,7 @@ function collectValues(value, previous = []) {
|
|
|
10169
10836
|
return previous;
|
|
10170
10837
|
}
|
|
10171
10838
|
// src/lib/route/fields.ts
|
|
10172
|
-
import { createHash as
|
|
10839
|
+
import { createHash as createHash4 } from "crypto";
|
|
10173
10840
|
function eventData(event) {
|
|
10174
10841
|
const data = event.data;
|
|
10175
10842
|
if (data && typeof data === "object" && !Array.isArray(data))
|
|
@@ -10189,10 +10856,10 @@ function slugSegment2(value, fallback = "event") {
|
|
|
10189
10856
|
return value.toLowerCase().replace(/[^a-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || fallback;
|
|
10190
10857
|
}
|
|
10191
10858
|
function stableSuffix(value) {
|
|
10192
|
-
return
|
|
10859
|
+
return createHash4("sha256").update(value).digest("hex").slice(0, 12);
|
|
10193
10860
|
}
|
|
10194
10861
|
function stableHash(parts) {
|
|
10195
|
-
return
|
|
10862
|
+
return createHash4("sha256").update(parts.map((part) => JSON.stringify(part)).join(`
|
|
10196
10863
|
`)).digest("hex").slice(0, 16);
|
|
10197
10864
|
}
|
|
10198
10865
|
function taskEventField(data, keys) {
|
|
@@ -10457,8 +11124,7 @@ function writeRouteEvidence(kind, value, evidenceDir) {
|
|
|
10457
11124
|
mkdirSync9(evidenceDir, { recursive: true, mode: 448 });
|
|
10458
11125
|
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\./g, "");
|
|
10459
11126
|
const evidencePath = join9(evidenceDir, `${kind}-${stamp}-${randomUUID().slice(0, 8)}.json`);
|
|
10460
|
-
|
|
10461
|
-
writeFileSync5(evidencePath, encoded, { mode: 384, flag: "wx" });
|
|
11127
|
+
writeFileSync5(evidencePath, JSON.stringify(value, null, 2), { mode: 384, flag: "wx" });
|
|
10462
11128
|
return evidencePath;
|
|
10463
11129
|
}
|
|
10464
11130
|
function selectRouteItems(items, maxActions, cursorKey, fingerprintOf) {
|
|
@@ -11054,70 +11720,6 @@ function todosTaskRouteTemplateId(opts) {
|
|
|
11054
11720
|
}
|
|
11055
11721
|
return id;
|
|
11056
11722
|
}
|
|
11057
|
-
function sourceCandidateRecords(data, metadata) {
|
|
11058
|
-
const records = [data, metadata];
|
|
11059
|
-
for (const record of [data, metadata]) {
|
|
11060
|
-
const sourceTask = objectField2(record.source_task) ?? objectField2(record.sourceTask);
|
|
11061
|
-
if (sourceTask)
|
|
11062
|
-
records.push(sourceTask);
|
|
11063
|
-
}
|
|
11064
|
-
return records;
|
|
11065
|
-
}
|
|
11066
|
-
function sourceStringField(records, keys) {
|
|
11067
|
-
for (const record of records) {
|
|
11068
|
-
for (const key of keys) {
|
|
11069
|
-
const value = stringField2(record[key]);
|
|
11070
|
-
if (value)
|
|
11071
|
-
return value;
|
|
11072
|
-
}
|
|
11073
|
-
}
|
|
11074
|
-
return;
|
|
11075
|
-
}
|
|
11076
|
-
function sourceBooleanField(records, keys) {
|
|
11077
|
-
for (const record of records) {
|
|
11078
|
-
for (const key of keys) {
|
|
11079
|
-
const value = record[key];
|
|
11080
|
-
if (typeof value === "boolean")
|
|
11081
|
-
return value;
|
|
11082
|
-
if (value === "true" || value === "1" || value === 1)
|
|
11083
|
-
return true;
|
|
11084
|
-
if (value === "false" || value === "0" || value === 0)
|
|
11085
|
-
return false;
|
|
11086
|
-
}
|
|
11087
|
-
}
|
|
11088
|
-
return;
|
|
11089
|
-
}
|
|
11090
|
-
function todosTaskSourceRef(data, metadata, taskId) {
|
|
11091
|
-
const records = sourceCandidateRecords(data, metadata);
|
|
11092
|
-
const sourceTaskKey = sourceStringField(records, ["source_task_key", "sourceTaskKey"]);
|
|
11093
|
-
const sourceStoreId = sourceStringField(records, ["source_store_id", "sourceStoreId"]);
|
|
11094
|
-
const sourceRepoPath = sourceStringField(records, ["source_repo_path", "sourceRepoPath"]);
|
|
11095
|
-
const sourceDbPath = sourceStringField(records, ["source_db_path", "sourceDbPath"]);
|
|
11096
|
-
const sourceSelectedByInput = sourceBooleanField(records, ["source_selected_by_input", "sourceSelectedByInput"]);
|
|
11097
|
-
const idempotencySource = sourceTaskKey ? "source_task_key" : sourceStoreId ? "source_store_id" : "legacy_task_id";
|
|
11098
|
-
return {
|
|
11099
|
-
sourceTaskKey,
|
|
11100
|
-
sourceStoreId,
|
|
11101
|
-
sourceRepoPath,
|
|
11102
|
-
sourceDbPath,
|
|
11103
|
-
sourceSelectedByInput,
|
|
11104
|
-
idempotencySource,
|
|
11105
|
-
idempotencyKey: sourceTaskKey ? `todos-task:${sourceTaskKey}` : sourceStoreId ? `todos-task:${sourceStoreId}:${taskId}` : `todos-task:${taskId}`
|
|
11106
|
-
};
|
|
11107
|
-
}
|
|
11108
|
-
function publicSourceTask(sourceTask) {
|
|
11109
|
-
if (!sourceTask.sourceTaskKey && !sourceTask.sourceStoreId && !sourceTask.sourceRepoPath && !sourceTask.sourceDbPath && sourceTask.sourceSelectedByInput === undefined) {
|
|
11110
|
-
return;
|
|
11111
|
-
}
|
|
11112
|
-
return {
|
|
11113
|
-
source_task_key: sourceTask.sourceTaskKey,
|
|
11114
|
-
source_store_id: sourceTask.sourceStoreId,
|
|
11115
|
-
source_repo_path: sourceTask.sourceRepoPath,
|
|
11116
|
-
source_db_path: sourceTask.sourceDbPath,
|
|
11117
|
-
source_selected_by_input: sourceTask.sourceSelectedByInput,
|
|
11118
|
-
idempotency_source: sourceTask.idempotencySource
|
|
11119
|
-
};
|
|
11120
|
-
}
|
|
11121
11723
|
async function readEventEnvelopeInput(opts = {}) {
|
|
11122
11724
|
const raw = opts.eventJson ?? (opts.eventFile ? readFileSync8(opts.eventFile, "utf8") : process.env.HASNA_EVENT_JSON || await Bun.stdin.text());
|
|
11123
11725
|
const event = JSON.parse(raw);
|
|
@@ -11294,13 +11896,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11294
11896
|
const taskId = taskEventField(data, ["id", "task_id", "taskId"]);
|
|
11295
11897
|
if (!taskId)
|
|
11296
11898
|
throw new ValidationError("todos task event is missing task id in data.id, data.task_id, data.task.id, or data.payload.id");
|
|
11297
|
-
const sourceTask = todosTaskSourceRef(data, metadata, taskId);
|
|
11298
|
-
const sourceTaskPublic = publicSourceTask(sourceTask);
|
|
11299
11899
|
const eligibility = taskRouteEligibility(data, metadata);
|
|
11300
11900
|
if (!eligibility.eligible) {
|
|
11301
11901
|
return {
|
|
11302
11902
|
kind: "skipped",
|
|
11303
|
-
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility
|
|
11903
|
+
value: { skipped: true, reason: eligibility.reason, event, taskId, eligibility },
|
|
11304
11904
|
human: `skipped task ${taskId}: ${eligibility.reason}`
|
|
11305
11905
|
};
|
|
11306
11906
|
}
|
|
@@ -11315,11 +11915,11 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11315
11915
|
"project_canonical_path",
|
|
11316
11916
|
"cwd"
|
|
11317
11917
|
]);
|
|
11318
|
-
const projectPath = dataProjectPath ?? metadataProjectPath ??
|
|
11918
|
+
const projectPath = dataProjectPath ?? metadataProjectPath ?? opts.projectPath ?? process.cwd();
|
|
11319
11919
|
const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve7(projectPath);
|
|
11320
11920
|
const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
|
|
11321
11921
|
const throttleLimits = routeThrottleLimitsFromOpts(opts);
|
|
11322
|
-
const idempotencyKey =
|
|
11922
|
+
const idempotencyKey = `todos-task:${taskId}`;
|
|
11323
11923
|
const idempotencySuffix = stableSuffix(idempotencyKey);
|
|
11324
11924
|
const namePrefix = opts.namePrefix ?? "event:todos-task";
|
|
11325
11925
|
const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
|
|
@@ -11332,7 +11932,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11332
11932
|
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
11333
11933
|
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
11334
11934
|
const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
|
|
11335
|
-
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras:
|
|
11935
|
+
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
11336
11936
|
}
|
|
11337
11937
|
} finally {
|
|
11338
11938
|
store.close();
|
|
@@ -11395,14 +11995,12 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11395
11995
|
prHandoff: templateId === TASK_LIFECYCLE_TEMPLATE_ID ? Boolean(opts.prHandoff) : false,
|
|
11396
11996
|
eventId: event.id,
|
|
11397
11997
|
eventType: event.type,
|
|
11398
|
-
todosProjectPath:
|
|
11399
|
-
todosDbPath: sourceTask.sourceDbPath
|
|
11998
|
+
todosProjectPath: opts.todosProject
|
|
11400
11999
|
};
|
|
11401
12000
|
const workflowContext = {
|
|
11402
12001
|
name: workflowName,
|
|
11403
12002
|
type: "todos-task-event-workflow",
|
|
11404
|
-
event: event.id
|
|
11405
|
-
sourceTask: sourceTaskPublic
|
|
12003
|
+
event: event.id
|
|
11406
12004
|
};
|
|
11407
12005
|
let workflowBody = templateId === TASK_LIFECYCLE_TEMPLATE_ID ? renderTaskLifecycleWorkflow(workflowInput) : renderTodosTaskWorkerVerifierWorkflow(workflowInput);
|
|
11408
12006
|
workflowBody.name = workflowName;
|
|
@@ -11415,13 +12013,13 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11415
12013
|
kind: "event",
|
|
11416
12014
|
id: event.id,
|
|
11417
12015
|
dedupeKey: idempotencyKey,
|
|
11418
|
-
raw: { type: event.type, source: event.source, subject: event.subject
|
|
12016
|
+
raw: { type: event.type, source: event.source, subject: event.subject }
|
|
11419
12017
|
},
|
|
11420
12018
|
subjectRef: {
|
|
11421
12019
|
kind: "task",
|
|
11422
12020
|
id: taskId,
|
|
11423
12021
|
path: routeProjectPath,
|
|
11424
|
-
raw: { title: taskTitle, description: taskDescription
|
|
12022
|
+
raw: { title: taskTitle, description: taskDescription }
|
|
11425
12023
|
},
|
|
11426
12024
|
intent: "route",
|
|
11427
12025
|
scope: {
|
|
@@ -11434,8 +12032,7 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11434
12032
|
accountPolicy: providerRouting.authProfilePool?.length || providerRouting.accountPool?.length ? "pool" : hasExplicitRoleAccount ? "role-explicit" : "single",
|
|
11435
12033
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11436
12034
|
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined,
|
|
11437
|
-
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
11438
|
-
sourceTask: sourceTaskPublic
|
|
12035
|
+
concurrencyGroup: projectGroup ?? routeProjectPath
|
|
11439
12036
|
},
|
|
11440
12037
|
outputPolicy: {
|
|
11441
12038
|
report: "always",
|
|
@@ -11460,10 +12057,9 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11460
12057
|
humanSubject: `task ${taskId}`,
|
|
11461
12058
|
valueExtras: {
|
|
11462
12059
|
providerRouting: providerRoutingPublic(providerRouting),
|
|
11463
|
-
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
|
|
11464
|
-
sourceTask: sourceTaskPublic
|
|
12060
|
+
prReviewRouting: prReviewRouting.required ? prReviewRouting : undefined
|
|
11465
12061
|
},
|
|
11466
|
-
dedupeValueExtras:
|
|
12062
|
+
dedupeValueExtras: {}
|
|
11467
12063
|
});
|
|
11468
12064
|
}
|
|
11469
12065
|
function routeGenericEvent(event, opts) {
|
|
@@ -11600,7 +12196,7 @@ function runLocalCommand(command, args, opts = {}) {
|
|
|
11600
12196
|
encoding: "utf8",
|
|
11601
12197
|
timeout: opts.timeoutMs ?? 30000,
|
|
11602
12198
|
maxBuffer: opts.maxBuffer ?? 8 * 1024 * 1024,
|
|
11603
|
-
env:
|
|
12199
|
+
env: process.env
|
|
11604
12200
|
});
|
|
11605
12201
|
return {
|
|
11606
12202
|
ok: result.status === 0,
|
|
@@ -11667,66 +12263,8 @@ function taskField(task, keys) {
|
|
|
11667
12263
|
}
|
|
11668
12264
|
return;
|
|
11669
12265
|
}
|
|
11670
|
-
function
|
|
11671
|
-
|
|
11672
|
-
const value = task[key];
|
|
11673
|
-
if (typeof value === "boolean")
|
|
11674
|
-
return value;
|
|
11675
|
-
if (value === "true" || value === "1" || value === 1)
|
|
11676
|
-
return true;
|
|
11677
|
-
if (value === "false" || value === "0" || value === 0)
|
|
11678
|
-
return false;
|
|
11679
|
-
}
|
|
11680
|
-
return;
|
|
11681
|
-
}
|
|
11682
|
-
function normalizedSourceRoots(opts) {
|
|
11683
|
-
return listFromRepeatedOpts(opts.todosSourceRoot) ?? [];
|
|
11684
|
-
}
|
|
11685
|
-
function normalizedSourceStores(opts) {
|
|
11686
|
-
return listFromRepeatedOpts(opts.todosSourceStore) ?? [];
|
|
11687
|
-
}
|
|
11688
|
-
function normalizedSourceIncludes(opts) {
|
|
11689
|
-
return listFromRepeatedOpts(opts.todosSourceInclude) ?? [];
|
|
11690
|
-
}
|
|
11691
|
-
function normalizedSourceExcludes(opts) {
|
|
11692
|
-
return listFromRepeatedOpts(opts.todosSourceExclude) ?? [];
|
|
11693
|
-
}
|
|
11694
|
-
function hasTodosSourceOptions(opts) {
|
|
11695
|
-
return Boolean(normalizedSourceRoots(opts).length || normalizedSourceStores(opts).length || normalizedSourceIncludes(opts).length || normalizedSourceExcludes(opts).length);
|
|
11696
|
-
}
|
|
11697
|
-
function taskSourceIdentity(task, taskId) {
|
|
11698
|
-
const sourceTaskKey = taskField(task, ["source_task_key", "sourceTaskKey"]);
|
|
11699
|
-
const sourceStoreId = taskField(task, ["source_store_id", "sourceStoreId"]);
|
|
11700
|
-
const identity = sourceTaskKey ?? (sourceStoreId && taskId ? `${sourceStoreId}:${taskId}` : undefined);
|
|
11701
|
-
return {
|
|
11702
|
-
sourceTaskKey,
|
|
11703
|
-
sourceStoreId,
|
|
11704
|
-
sourceRepoPath: taskField(task, ["source_repo_path", "sourceRepoPath"]),
|
|
11705
|
-
sourceDbPath: taskField(task, ["source_db_path", "sourceDbPath"]),
|
|
11706
|
-
sourceSelectedByInput: booleanTaskField(task, ["source_selected_by_input", "sourceSelectedByInput"]),
|
|
11707
|
-
idempotencyIdentity: identity
|
|
11708
|
-
};
|
|
11709
|
-
}
|
|
11710
|
-
function publicSourceTaskIdentity(source) {
|
|
11711
|
-
if (!source.sourceTaskKey && !source.sourceStoreId && !source.sourceRepoPath && !source.sourceDbPath && source.sourceSelectedByInput === undefined) {
|
|
11712
|
-
return;
|
|
11713
|
-
}
|
|
11714
|
-
return {
|
|
11715
|
-
source_task_key: source.sourceTaskKey,
|
|
11716
|
-
source_store_id: source.sourceStoreId,
|
|
11717
|
-
source_repo_path: source.sourceRepoPath,
|
|
11718
|
-
source_db_path: source.sourceDbPath,
|
|
11719
|
-
source_selected_by_input: source.sourceSelectedByInput
|
|
11720
|
-
};
|
|
11721
|
-
}
|
|
11722
|
-
function taskListValues(task) {
|
|
11723
|
-
const values = [
|
|
11724
|
-
taskField(task, ["task_list_id", "taskListId", "task_list_slug", "taskListSlug", "task_list_name", "taskListName"]),
|
|
11725
|
-
stringField2(task.task_list?.id),
|
|
11726
|
-
stringField2(task.task_list?.slug),
|
|
11727
|
-
stringField2(task.task_list?.name)
|
|
11728
|
-
].filter((value) => Boolean(value));
|
|
11729
|
-
return [...new Set(values)];
|
|
12266
|
+
function taskListId(task) {
|
|
12267
|
+
return taskField(task, ["task_list_id", "taskListId"]) ?? stringField2(task.task_list?.id);
|
|
11730
12268
|
}
|
|
11731
12269
|
function taskProjectId(task) {
|
|
11732
12270
|
return taskField(task, ["project_id", "projectId"]);
|
|
@@ -11740,14 +12278,12 @@ function taskProjectPath(task) {
|
|
|
11740
12278
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11741
12279
|
return taskField(task, ["project_path", "projectPath"]) ?? taskEventField(metadata, ["project_path", "projectPath", "project_canonical_path"]) ?? taskDescriptionProjectPath(task) ?? taskField(task, ["working_dir", "workingDir", "cwd"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "cwd"]);
|
|
11742
12280
|
}
|
|
11743
|
-
function taskDrainEvent(task
|
|
12281
|
+
function taskDrainEvent(task) {
|
|
11744
12282
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11745
12283
|
if (!taskId)
|
|
11746
12284
|
throw new Error("todos ready returned a task without an id");
|
|
11747
12285
|
const metadata = objectField2(task.metadata) ?? {};
|
|
11748
12286
|
const workingDir = taskProjectPath(task);
|
|
11749
|
-
const sourceIdentity = taskSourceIdentity(task, taskId);
|
|
11750
|
-
const sourceTask = publicSourceTaskIdentity(sourceIdentity);
|
|
11751
12287
|
const data = {
|
|
11752
12288
|
...task,
|
|
11753
12289
|
id: taskId,
|
|
@@ -11757,21 +12293,14 @@ function taskDrainEvent(task, opts = {}) {
|
|
|
11757
12293
|
tags: tagsFromValue2(task.tags),
|
|
11758
12294
|
metadata
|
|
11759
12295
|
};
|
|
11760
|
-
if (opts.sourceRouteEligible) {
|
|
11761
|
-
data.route_enabled = true;
|
|
11762
|
-
data.route_enabled_by = "source_route_state";
|
|
11763
|
-
}
|
|
11764
12296
|
if (workingDir) {
|
|
11765
12297
|
data.working_dir = workingDir;
|
|
11766
12298
|
data.project_path = taskField(task, ["project_path", "projectPath"]) ?? workingDir;
|
|
11767
12299
|
data.cwd = taskField(task, ["cwd"]) ?? workingDir;
|
|
11768
12300
|
}
|
|
11769
|
-
if (sourceTask)
|
|
11770
|
-
data.source_task = sourceTask;
|
|
11771
|
-
const eventId = sourceIdentity.idempotencyIdentity ? `drain-todos-task-${slugSegment2(taskId, "task")}-${stableSuffix(sourceIdentity.idempotencyIdentity)}` : `drain-todos-task-${taskId}`;
|
|
11772
12301
|
const time = new Date().toISOString();
|
|
11773
12302
|
return {
|
|
11774
|
-
id:
|
|
12303
|
+
id: `drain-todos-task-${taskId}`,
|
|
11775
12304
|
type: "task.created",
|
|
11776
12305
|
source: "@hasna/todos",
|
|
11777
12306
|
subject: taskId,
|
|
@@ -11782,8 +12311,6 @@ function taskDrainEvent(task, opts = {}) {
|
|
|
11782
12311
|
metadata: {
|
|
11783
12312
|
...metadata,
|
|
11784
12313
|
...workingDir ? { working_dir: workingDir, project_path: data.project_path, cwd: data.cwd } : {},
|
|
11785
|
-
...sourceTask,
|
|
11786
|
-
...sourceTask ? { source_task: sourceTask } : {},
|
|
11787
12314
|
drained_by: "@hasna/loops",
|
|
11788
12315
|
drained_from: "todos ready"
|
|
11789
12316
|
}
|
|
@@ -11792,7 +12319,6 @@ function taskDrainEvent(task, opts = {}) {
|
|
|
11792
12319
|
function compactDrainResult(result) {
|
|
11793
12320
|
const value = result.value;
|
|
11794
12321
|
const event = objectField2(value.event);
|
|
11795
|
-
const sourceTask = objectField2(value.sourceTask) ?? objectField2(objectField2(event?.metadata)?.source_task) ?? objectField2(objectField2(event?.data)?.source_task);
|
|
11796
12322
|
const loop = objectField2(value.loop);
|
|
11797
12323
|
const workflow = objectField2(value.workflow);
|
|
11798
12324
|
const throttle = objectField2(value.throttle);
|
|
@@ -11809,75 +12335,26 @@ function compactDrainResult(result) {
|
|
|
11809
12335
|
workflowId: stringField2(workflow?.id),
|
|
11810
12336
|
workflowName: stringField2(workflow?.name),
|
|
11811
12337
|
providerRouting,
|
|
11812
|
-
sourceTask,
|
|
11813
12338
|
requeue,
|
|
11814
12339
|
queuedAtSource: value.queuedAtSource
|
|
11815
12340
|
};
|
|
11816
12341
|
}
|
|
11817
|
-
function
|
|
11818
|
-
|
|
11819
|
-
|
|
11820
|
-
function parseTodosReadyJson(stdout) {
|
|
11821
|
-
try {
|
|
11822
|
-
return JSON.parse(stdout || "[]");
|
|
11823
|
-
} catch (error) {
|
|
11824
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
11825
|
-
throw new Error(`failed to parse todos ready --json output (${stdout.length} bytes): ${message}`);
|
|
11826
|
-
}
|
|
11827
|
-
}
|
|
11828
|
-
function loadReadyTodosTasks(opts, scanLimit, sourceMode) {
|
|
11829
|
-
const sourceRoots = normalizedSourceRoots(opts);
|
|
11830
|
-
const sourceStores = normalizedSourceStores(opts);
|
|
11831
|
-
const sourceIncludes = normalizedSourceIncludes(opts);
|
|
11832
|
-
const sourceExcludes = normalizedSourceExcludes(opts);
|
|
11833
|
-
if (sourceMode && !sourceRoots.length && !sourceStores.length) {
|
|
11834
|
-
throw new ValidationError("source discovery requires at least one --todos-source-root or --todos-source-store");
|
|
11835
|
-
}
|
|
11836
|
-
const args = sourceMode ? ["--json", "ready"] : ["--project", opts.todosProject ?? defaultLoopsProject(), "--json", "ready"];
|
|
11837
|
-
if (sourceMode) {
|
|
11838
|
-
for (const root of sourceRoots)
|
|
11839
|
-
args.push("--source-root", root);
|
|
11840
|
-
for (const store of sourceStores)
|
|
11841
|
-
args.push("--source-store", store);
|
|
11842
|
-
for (const pattern of sourceIncludes)
|
|
11843
|
-
args.push("--include", pattern);
|
|
11844
|
-
for (const pattern of sourceExcludes)
|
|
11845
|
-
args.push("--exclude", pattern);
|
|
11846
|
-
}
|
|
11847
|
-
args.push("--limit", String(scanLimit));
|
|
12342
|
+
function loadReadyTodosTasks(opts, scanLimit) {
|
|
12343
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
12344
|
+
const args = ["--project", todosProject, "--json", "ready", "--limit", String(scanLimit)];
|
|
11848
12345
|
const result = runLocalCommandWithStdoutFile("todos", args, { timeoutMs: 60000, maxBuffer: 64 * 1024 * 1024 });
|
|
11849
12346
|
if (!result.ok)
|
|
11850
12347
|
throw new Error(result.stderr || result.error || "todos ready failed");
|
|
11851
|
-
|
|
11852
|
-
|
|
11853
|
-
|
|
11854
|
-
|
|
11855
|
-
|
|
11856
|
-
|
|
11857
|
-
|
|
11858
|
-
if (!
|
|
11859
|
-
throw new Error("todos ready --json
|
|
11860
|
-
|
|
11861
|
-
throw new Error(`todos ready source discovery returned unsupported schema_version: ${String(response.schema_version)}`);
|
|
11862
|
-
}
|
|
11863
|
-
if (!Array.isArray(response.candidates))
|
|
11864
|
-
throw new Error("todos ready source discovery returned missing candidates array");
|
|
11865
|
-
return {
|
|
11866
|
-
tasks: response.candidates,
|
|
11867
|
-
sourceMode,
|
|
11868
|
-
sourceRoots,
|
|
11869
|
-
sourceStores,
|
|
11870
|
-
sourceIncludes,
|
|
11871
|
-
sourceExcludes,
|
|
11872
|
-
sourceDiscovery: {
|
|
11873
|
-
schemaVersion: "todos.task_route_sources.v1",
|
|
11874
|
-
stores: Array.isArray(response.stores) ? response.stores : undefined,
|
|
11875
|
-
errors: Array.isArray(response.errors) ? response.errors : undefined,
|
|
11876
|
-
totalCandidateCount: numberField2(response.total_candidate_count),
|
|
11877
|
-
returnedCandidateCount: numberField2(response.returned_candidate_count),
|
|
11878
|
-
truncated: typeof response.truncated === "boolean" ? response.truncated : undefined
|
|
11879
|
-
}
|
|
11880
|
-
};
|
|
12348
|
+
let parsed;
|
|
12349
|
+
try {
|
|
12350
|
+
parsed = JSON.parse(result.stdout || "[]");
|
|
12351
|
+
} catch (error) {
|
|
12352
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
12353
|
+
throw new Error(`failed to parse todos ready --json output (${result.stdout.length} bytes): ${message}`);
|
|
12354
|
+
}
|
|
12355
|
+
if (!Array.isArray(parsed))
|
|
12356
|
+
throw new Error("todos ready --json returned a non-array value");
|
|
12357
|
+
return parsed;
|
|
11881
12358
|
}
|
|
11882
12359
|
function resolveTaskListFilter(todosProject, filter) {
|
|
11883
12360
|
const wanted = filter?.trim();
|
|
@@ -11893,7 +12370,7 @@ function resolveTaskListFilter(todosProject, filter) {
|
|
|
11893
12370
|
function taskMatchesDrainFilters(task, filters) {
|
|
11894
12371
|
if (filters.projectId && taskProjectId(task) !== filters.projectId)
|
|
11895
12372
|
return false;
|
|
11896
|
-
if (filters.
|
|
12373
|
+
if (filters.taskListId && taskListId(task) !== filters.taskListId)
|
|
11897
12374
|
return false;
|
|
11898
12375
|
if (filters.projectPathPrefix) {
|
|
11899
12376
|
const path = taskProjectPath(task);
|
|
@@ -11931,77 +12408,19 @@ function skippedDrainTask(task, event, reason, extra = {}) {
|
|
|
11931
12408
|
function isSkippableDrainRouteError(message) {
|
|
11932
12409
|
return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
|
|
11933
12410
|
}
|
|
11934
|
-
function
|
|
11935
|
-
return objectField2(task.route_state) ?? objectField2(task.routeState);
|
|
11936
|
-
}
|
|
11937
|
-
function sourceRouteReason(routeState) {
|
|
11938
|
-
const reasons = routeState?.reasons;
|
|
11939
|
-
const firstReason = Array.isArray(reasons) ? stringField2(reasons[0]) : undefined;
|
|
11940
|
-
return stringField2(routeState?.reason) ?? stringField2(routeState?.eligibility_reason) ?? stringField2(routeState?.eligibilityReason) ?? firstReason;
|
|
11941
|
-
}
|
|
11942
|
-
function sourceRouteEligibility(task) {
|
|
11943
|
-
const routeState = sourceRouteState(task);
|
|
11944
|
-
if (routeState?.eligible === true)
|
|
11945
|
-
return { eligible: true, routeState };
|
|
11946
|
-
const reason = sourceRouteReason(routeState) ?? "source route_state.eligible is not true";
|
|
11947
|
-
return { eligible: false, reason, routeState };
|
|
11948
|
-
}
|
|
11949
|
-
function sourceTaskMutationTarget(todosProject, task, opts = {}) {
|
|
11950
|
-
const source = taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"]));
|
|
11951
|
-
if (source.sourceDbPath) {
|
|
11952
|
-
return {
|
|
11953
|
-
argsPrefix: source.sourceRepoPath ? ["--project", source.sourceRepoPath] : [],
|
|
11954
|
-
env: {
|
|
11955
|
-
TODOS_DB_PATH: source.sourceDbPath,
|
|
11956
|
-
HASNA_TODOS_DB_PATH: source.sourceDbPath
|
|
11957
|
-
},
|
|
11958
|
-
project: source.sourceRepoPath,
|
|
11959
|
-
sourceDbPath: source.sourceDbPath,
|
|
11960
|
-
sourceRepoPath: source.sourceRepoPath,
|
|
11961
|
-
sourceStoreId: source.sourceStoreId
|
|
11962
|
-
};
|
|
11963
|
-
}
|
|
11964
|
-
if (source.sourceRepoPath) {
|
|
11965
|
-
return {
|
|
11966
|
-
argsPrefix: ["--project", source.sourceRepoPath],
|
|
11967
|
-
project: source.sourceRepoPath,
|
|
11968
|
-
sourceRepoPath: source.sourceRepoPath,
|
|
11969
|
-
sourceStoreId: source.sourceStoreId
|
|
11970
|
-
};
|
|
11971
|
-
}
|
|
11972
|
-
if (opts.sourceMode)
|
|
11973
|
-
return;
|
|
11974
|
-
const project = todosProject ?? defaultLoopsProject();
|
|
11975
|
-
return { argsPrefix: ["--project", project], project };
|
|
11976
|
-
}
|
|
11977
|
-
function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {}) {
|
|
12411
|
+
function markInvalidDrainTaskNonRouteable(todosProject, task, reason) {
|
|
11978
12412
|
const taskId = taskField(task, ["id", "task_id", "taskId"]);
|
|
11979
12413
|
if (!taskId)
|
|
11980
12414
|
return { attempted: false, reason: "task id missing" };
|
|
11981
12415
|
const comment = `OpenLoops route blocked for task ${taskId}: ${reason}. Added no-auto and removed auto:route so route drains do not repeatedly route this task until its project path is fixed.`;
|
|
11982
|
-
const
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
attempted: false,
|
|
11986
|
-
taskId,
|
|
11987
|
-
reason: "source task missing source_db_path or source_repo_path; refusing to update router/default Todos store"
|
|
11988
|
-
};
|
|
11989
|
-
}
|
|
11990
|
-
const commentResult = runLocalCommand("todos", [...target.argsPrefix, "comment", taskId, comment], { timeoutMs: 30000, env: target.env });
|
|
11991
|
-
const tagResult = runLocalCommand("todos", [...target.argsPrefix, "tag", taskId, "no-auto"], { timeoutMs: 30000, env: target.env });
|
|
11992
|
-
const untagResult = runLocalCommand("todos", [...target.argsPrefix, "untag", taskId, "auto:route"], { timeoutMs: 30000, env: target.env });
|
|
12416
|
+
const commentResult = runLocalCommand("todos", ["--project", todosProject, "comment", taskId, comment], { timeoutMs: 30000 });
|
|
12417
|
+
const tagResult = runLocalCommand("todos", ["--project", todosProject, "tag", taskId, "no-auto"], { timeoutMs: 30000 });
|
|
12418
|
+
const untagResult = runLocalCommand("todos", ["--project", todosProject, "untag", taskId, "auto:route"], { timeoutMs: 30000 });
|
|
11993
12419
|
const ok = commentResult.ok && tagResult.ok && untagResult.ok;
|
|
11994
12420
|
return {
|
|
11995
12421
|
ok,
|
|
11996
12422
|
attempted: true,
|
|
11997
12423
|
taskId,
|
|
11998
|
-
target: {
|
|
11999
|
-
project: target.project,
|
|
12000
|
-
source_db_path: target.sourceDbPath,
|
|
12001
|
-
source_repo_path: target.sourceRepoPath,
|
|
12002
|
-
source_store_id: target.sourceStoreId,
|
|
12003
|
-
used_db_env: Boolean(target.sourceDbPath)
|
|
12004
|
-
},
|
|
12005
12424
|
error: ok ? undefined : "one or more source task updates failed; inspect per-command results",
|
|
12006
12425
|
comment: todosMutationSummary(commentResult),
|
|
12007
12426
|
tagNoAuto: todosMutationSummary(tagResult),
|
|
@@ -12010,19 +12429,17 @@ function markInvalidDrainTaskNonRouteable(todosProject, task, reason, opts = {})
|
|
|
12010
12429
|
}
|
|
12011
12430
|
function drainTodosTaskRoutes(opts) {
|
|
12012
12431
|
const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
|
|
12013
|
-
const
|
|
12014
|
-
const todosProject = sourceMode ? opts.todosProject : opts.todosProject ?? defaultLoopsProject();
|
|
12432
|
+
const todosProject = opts.todosProject ?? defaultLoopsProject();
|
|
12015
12433
|
const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
|
|
12016
|
-
const taskListFilter =
|
|
12434
|
+
const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
|
|
12017
12435
|
const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
|
|
12018
12436
|
const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
|
|
12019
12437
|
const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
|
|
12020
12438
|
const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
|
|
12021
|
-
const
|
|
12022
|
-
const ready = loaded.tasks;
|
|
12439
|
+
const ready = loadReadyTodosTasks(opts, scanLimit);
|
|
12023
12440
|
const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
|
|
12024
12441
|
projectId: opts.todosProjectId,
|
|
12025
|
-
|
|
12442
|
+
taskListId: taskListFilter,
|
|
12026
12443
|
projectPathPrefix: opts.projectPathPrefix,
|
|
12027
12444
|
tags: requiredTags
|
|
12028
12445
|
}));
|
|
@@ -12035,25 +12452,13 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12035
12452
|
let event;
|
|
12036
12453
|
let result;
|
|
12037
12454
|
try {
|
|
12038
|
-
|
|
12039
|
-
if (sourceMode) {
|
|
12040
|
-
sourceEligibility = sourceRouteEligibility(task);
|
|
12041
|
-
if (!sourceEligibility.eligible) {
|
|
12042
|
-
result = skippedDrainTask(task, undefined, sourceEligibility.reason ?? "source route_state.eligible is not true", {
|
|
12043
|
-
sourceRouteState: sourceEligibility.routeState,
|
|
12044
|
-
sourceTask: publicSourceTaskIdentity(taskSourceIdentity(task, taskField(task, ["id", "task_id", "taskId"])))
|
|
12045
|
-
});
|
|
12046
|
-
results.push(result);
|
|
12047
|
-
continue;
|
|
12048
|
-
}
|
|
12049
|
-
}
|
|
12050
|
-
event = taskDrainEvent(task, { sourceRouteEligible: sourceEligibility?.eligible === true });
|
|
12455
|
+
event = taskDrainEvent(task);
|
|
12051
12456
|
result = routeTodosTaskEvent(event, opts);
|
|
12052
12457
|
} catch (error) {
|
|
12053
12458
|
const message = error instanceof Error ? error.message : String(error);
|
|
12054
12459
|
if (!isSkippableDrainRouteError(message))
|
|
12055
12460
|
throw error;
|
|
12056
|
-
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message
|
|
12461
|
+
const sourceTaskUpdate = opts.dryRun ? { attempted: false, reason: "dry-run" } : markInvalidDrainTaskNonRouteable(todosProject, task, message);
|
|
12057
12462
|
result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed", { sourceTaskUpdate });
|
|
12058
12463
|
}
|
|
12059
12464
|
results.push(result);
|
|
@@ -12063,12 +12468,6 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12063
12468
|
const report = {
|
|
12064
12469
|
drainedAt: new Date().toISOString(),
|
|
12065
12470
|
todosProject,
|
|
12066
|
-
sourceMode,
|
|
12067
|
-
sourceRoots: loaded.sourceRoots,
|
|
12068
|
-
sourceStores: loaded.sourceStores,
|
|
12069
|
-
sourceIncludes: loaded.sourceIncludes,
|
|
12070
|
-
sourceExcludes: loaded.sourceExcludes,
|
|
12071
|
-
sourceDiscovery: loaded.sourceDiscovery,
|
|
12072
12471
|
templateId: todosTaskRouteTemplateId(opts),
|
|
12073
12472
|
todosProjectId: opts.todosProjectId,
|
|
12074
12473
|
taskList: opts.taskList,
|
|
@@ -12081,14 +12480,14 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12081
12480
|
scanned: ready.length,
|
|
12082
12481
|
candidates: candidates.length,
|
|
12083
12482
|
filteredCandidates: filteredCandidates.length,
|
|
12084
|
-
scanExhausted:
|
|
12483
|
+
scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
|
|
12085
12484
|
considered: results.length,
|
|
12086
12485
|
created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
|
|
12087
12486
|
deduped: results.filter((result) => result.kind === "deduped").length,
|
|
12088
12487
|
throttled: results.filter((result) => result.kind === "throttled").length,
|
|
12089
12488
|
skipped: results.filter((result) => result.kind === "skipped").length,
|
|
12090
12489
|
maxDispatch,
|
|
12091
|
-
source:
|
|
12490
|
+
source: "todos ready",
|
|
12092
12491
|
dryRun: Boolean(opts.dryRun),
|
|
12093
12492
|
results: results.map((result) => ({ kind: result.kind, ...result.value }))
|
|
12094
12493
|
};
|
|
@@ -12096,12 +12495,6 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12096
12495
|
const value = opts.compact ? {
|
|
12097
12496
|
drainedAt: report.drainedAt,
|
|
12098
12497
|
todosProject: report.todosProject,
|
|
12099
|
-
sourceMode: report.sourceMode,
|
|
12100
|
-
sourceRoots: report.sourceRoots,
|
|
12101
|
-
sourceStores: report.sourceStores,
|
|
12102
|
-
sourceIncludes: report.sourceIncludes,
|
|
12103
|
-
sourceExcludes: report.sourceExcludes,
|
|
12104
|
-
sourceDiscovery: report.sourceDiscovery,
|
|
12105
12498
|
templateId: report.templateId,
|
|
12106
12499
|
todosProjectId: report.todosProjectId,
|
|
12107
12500
|
taskList: report.taskList,
|
|
@@ -12127,7 +12520,7 @@ function drainTodosTaskRoutes(opts) {
|
|
|
12127
12520
|
results: results.map(compactDrainResult)
|
|
12128
12521
|
} : { ...report, evidencePath };
|
|
12129
12522
|
return {
|
|
12130
|
-
value
|
|
12523
|
+
value,
|
|
12131
12524
|
human: `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`
|
|
12132
12525
|
};
|
|
12133
12526
|
}
|
|
@@ -12405,34 +12798,6 @@ var EVENT_INPUT_OPTION_SPECS = [
|
|
|
12405
12798
|
{ flags: "--event-json <json>", key: "eventJson", kind: "value", description: "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON" }
|
|
12406
12799
|
];
|
|
12407
12800
|
var DRAIN_FILTER_OPTION_SPECS = [
|
|
12408
|
-
{
|
|
12409
|
-
flags: "--todos-source-root <path>",
|
|
12410
|
-
key: "todosSourceRoot",
|
|
12411
|
-
kind: "repeat",
|
|
12412
|
-
description: "scan todos route candidates discovered under this source root; may be repeated",
|
|
12413
|
-
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceRoot)
|
|
12414
|
-
},
|
|
12415
|
-
{
|
|
12416
|
-
flags: "--todos-source-store <path>",
|
|
12417
|
-
key: "todosSourceStore",
|
|
12418
|
-
kind: "repeat",
|
|
12419
|
-
description: "scan this explicit todos source store path; may be repeated",
|
|
12420
|
-
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceStore)
|
|
12421
|
-
},
|
|
12422
|
-
{
|
|
12423
|
-
flags: "--todos-source-include <pattern>",
|
|
12424
|
-
key: "todosSourceInclude",
|
|
12425
|
-
kind: "repeat",
|
|
12426
|
-
description: "include source discovery entries matching this pattern; may be repeated",
|
|
12427
|
-
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceInclude)
|
|
12428
|
-
},
|
|
12429
|
-
{
|
|
12430
|
-
flags: "--todos-source-exclude <pattern>",
|
|
12431
|
-
key: "todosSourceExclude",
|
|
12432
|
-
kind: "repeat",
|
|
12433
|
-
description: "exclude source discovery entries matching this pattern; may be repeated",
|
|
12434
|
-
serializeValue: (opts) => listFromRepeatedOpts(opts.todosSourceExclude)
|
|
12435
|
-
},
|
|
12436
12801
|
{ flags: "--todos-project-id <id>", key: "todosProjectId", kind: "value", description: "filter todos ready output to one todos project id" },
|
|
12437
12802
|
{ flags: "--task-list <id-or-slug>", key: "taskList", kind: "value", description: "filter ready tasks to one task-list id, slug, or name" },
|
|
12438
12803
|
{ flags: "--project-path-prefix <path>", key: "projectPathPrefix", kind: "value", description: "filter ready tasks to a project/repo path prefix" },
|
|
@@ -12931,6 +13296,53 @@ function parseVars(values) {
|
|
|
12931
13296
|
}
|
|
12932
13297
|
return vars;
|
|
12933
13298
|
}
|
|
13299
|
+
function parseJsonFile(file) {
|
|
13300
|
+
try {
|
|
13301
|
+
return JSON.parse(readFileSync10(file, "utf8"));
|
|
13302
|
+
} catch (error) {
|
|
13303
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13304
|
+
throw new ValidationError(`failed to read JSON file ${file}: ${reason}`);
|
|
13305
|
+
}
|
|
13306
|
+
}
|
|
13307
|
+
function parseStringMap(values, flag) {
|
|
13308
|
+
const result = {};
|
|
13309
|
+
for (const value of values ?? []) {
|
|
13310
|
+
const index = value.indexOf("=");
|
|
13311
|
+
if (index <= 0)
|
|
13312
|
+
throw new ValidationError(`invalid ${flag} value, expected key=value: ${value}`);
|
|
13313
|
+
result[value.slice(0, index)] = value.slice(index + 1);
|
|
13314
|
+
}
|
|
13315
|
+
return result;
|
|
13316
|
+
}
|
|
13317
|
+
function parseJsonMap(values, flag) {
|
|
13318
|
+
const result = {};
|
|
13319
|
+
for (const value of values ?? []) {
|
|
13320
|
+
const index = value.indexOf("=");
|
|
13321
|
+
if (index <= 0)
|
|
13322
|
+
throw new ValidationError(`invalid ${flag} value, expected key=json-or-string: ${value}`);
|
|
13323
|
+
const raw = value.slice(index + 1);
|
|
13324
|
+
try {
|
|
13325
|
+
result[value.slice(0, index)] = JSON.parse(raw);
|
|
13326
|
+
} catch {
|
|
13327
|
+
result[value.slice(0, index)] = raw;
|
|
13328
|
+
}
|
|
13329
|
+
}
|
|
13330
|
+
return result;
|
|
13331
|
+
}
|
|
13332
|
+
function printMigrationPlan(plan, opts = {}) {
|
|
13333
|
+
if (isJson() || opts.json) {
|
|
13334
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
13335
|
+
return;
|
|
13336
|
+
}
|
|
13337
|
+
console.log(`${plan.operation} dryRun=${plan.dryRun} workflows=${plan.summary.workflows} loops=${plan.summary.loops} runs=${plan.summary.runs} ` + `insert=${plan.summary.insert} update=${plan.summary.update} skip=${plan.summary.skip} conflict=${plan.summary.conflict} blocked=${plan.summary.blocked}`);
|
|
13338
|
+
for (const warning of plan.warnings)
|
|
13339
|
+
console.log(`warn ${warning}`);
|
|
13340
|
+
for (const row of plan.rows) {
|
|
13341
|
+
if (row.action !== "blocked" && row.action !== "conflict")
|
|
13342
|
+
continue;
|
|
13343
|
+
console.log(`${row.action} ${row.resource}:${row.name ?? row.id} ${row.reason ?? ""}`.trim());
|
|
13344
|
+
}
|
|
13345
|
+
}
|
|
12934
13346
|
function backupLoopsDatabase(reason) {
|
|
12935
13347
|
return backupDatabase({ reason, keep: 3 }).path;
|
|
12936
13348
|
}
|
|
@@ -13019,6 +13431,104 @@ var goal = program.command("goal").description("inspect goal runs");
|
|
|
13019
13431
|
program.command("mode").description("show the active OpenLoops deployment mode").option("--json", "print JSON").action(runAction(deploymentStatusCommand()));
|
|
13020
13432
|
var selfHosted = program.command("self-hosted").alias("selfhosted").description("inspect the self-hosted OpenLoops contract");
|
|
13021
13433
|
selfHosted.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("self_hosted")));
|
|
13434
|
+
program.command("export").description("export a local OpenLoops migration bundle").requiredOption("--file <path>", "write bundle JSON to this path").option("--dry-run", "preview the bundle without writing the file").option("--no-runs", "omit loop run history from the bundle").option("--allow-redacted", "write a redacted non-importable bundle when env/secrets must be removed").option("--json", "print JSON").action(runAction((opts) => {
|
|
13435
|
+
const store = new Store;
|
|
13436
|
+
try {
|
|
13437
|
+
const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.runs });
|
|
13438
|
+
if (!opts.dryRun && !bundle.importable && !opts.allowRedacted) {
|
|
13439
|
+
throw new ValidationError("export is not no-loss because redactions/blockers are present; rerun with --allow-redacted to write a redacted bundle");
|
|
13440
|
+
}
|
|
13441
|
+
if (!opts.dryRun)
|
|
13442
|
+
writeFileSync6(opts.file, `${JSON.stringify(bundle, null, 2)}
|
|
13443
|
+
`, { mode: 384 });
|
|
13444
|
+
const output = {
|
|
13445
|
+
ok: true,
|
|
13446
|
+
dryRun: Boolean(opts.dryRun),
|
|
13447
|
+
file: opts.file,
|
|
13448
|
+
bundle: publicMigrationBundle(bundle)
|
|
13449
|
+
};
|
|
13450
|
+
if (isJson() || opts.json)
|
|
13451
|
+
console.log(JSON.stringify(output, null, 2));
|
|
13452
|
+
else {
|
|
13453
|
+
console.log(`${opts.dryRun ? "would export" : "exported"} ${opts.file} workflows=${bundle.counts.workflows} loops=${bundle.counts.loops} runs=${bundle.counts.runs}`);
|
|
13454
|
+
for (const warning of bundle.warnings)
|
|
13455
|
+
console.log(`warn ${warning}`);
|
|
13456
|
+
}
|
|
13457
|
+
} finally {
|
|
13458
|
+
store.close();
|
|
13459
|
+
}
|
|
13460
|
+
}));
|
|
13461
|
+
program.command("import <file>").description("preview or apply a local OpenLoops migration bundle").option("--apply", "apply the import; default is a dry-run preview").option("--replace", "update existing rows whose ids match but hashes differ").option("--no-runs", "ignore loop run history in the bundle").option("--json", "print JSON").action(runAction((file, opts) => {
|
|
13462
|
+
const bundle = validateLoopsMigrationBundle(parseJsonFile(file));
|
|
13463
|
+
const store = new Store;
|
|
13464
|
+
try {
|
|
13465
|
+
if (!opts.apply) {
|
|
13466
|
+
printMigrationPlan(buildImportMigrationPlan(store, bundle, {
|
|
13467
|
+
includeRuns: opts.runs,
|
|
13468
|
+
replace: opts.replace,
|
|
13469
|
+
dryRun: true
|
|
13470
|
+
}), opts);
|
|
13471
|
+
return;
|
|
13472
|
+
}
|
|
13473
|
+
const plan = buildImportMigrationPlan(store, bundle, {
|
|
13474
|
+
includeRuns: opts.runs,
|
|
13475
|
+
replace: opts.replace,
|
|
13476
|
+
dryRun: false
|
|
13477
|
+
});
|
|
13478
|
+
if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
|
|
13479
|
+
printMigrationPlan(plan, opts);
|
|
13480
|
+
throw new ValidationError(`refusing to import unsafe bundle: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
|
|
13481
|
+
}
|
|
13482
|
+
const backupPath = backupLoopsDatabase("migration-import");
|
|
13483
|
+
const result = applyImportMigrationBundle(store, bundle, {
|
|
13484
|
+
includeRuns: opts.runs,
|
|
13485
|
+
replace: opts.replace,
|
|
13486
|
+
dryRun: false
|
|
13487
|
+
});
|
|
13488
|
+
const output = { ok: true, backupPath, ...result };
|
|
13489
|
+
if (isJson() || opts.json)
|
|
13490
|
+
console.log(JSON.stringify(output, null, 2));
|
|
13491
|
+
else {
|
|
13492
|
+
console.log(`imported workflows=${result.applied.workflows} loops=${result.applied.loops} runs=${result.applied.runs}${backupPath ? ` backup=${backupPath}` : ""}`);
|
|
13493
|
+
}
|
|
13494
|
+
} finally {
|
|
13495
|
+
store.close();
|
|
13496
|
+
}
|
|
13497
|
+
}));
|
|
13498
|
+
function selfHostedMigrationCommand(operation) {
|
|
13499
|
+
return runAction(async (opts) => {
|
|
13500
|
+
const store = new Store;
|
|
13501
|
+
try {
|
|
13502
|
+
const plan = await buildSelfHostedMigrationPlan(store, {
|
|
13503
|
+
operation,
|
|
13504
|
+
apiUrl: opts.apiUrl,
|
|
13505
|
+
includeRuns: opts.runs
|
|
13506
|
+
});
|
|
13507
|
+
printMigrationPlan(plan, opts);
|
|
13508
|
+
} finally {
|
|
13509
|
+
store.close();
|
|
13510
|
+
}
|
|
13511
|
+
});
|
|
13512
|
+
}
|
|
13513
|
+
selfHosted.command("migrate").description("preview local-to-self-hosted migration actions").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted migrate does not apply remote changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-migrate"));
|
|
13514
|
+
selfHosted.command("push").description("preview local rows that would be pushed to self-hosted").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted push does not apply remote changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-push"));
|
|
13515
|
+
selfHosted.command("pull").description("preview self-hosted rows that would be pulled locally").option("--api-url <url>", "self-hosted control-plane API URL").option("--dry-run", "preview only; self-hosted pull does not apply local changes yet").option("--no-runs", "omit loop run history from the preview").option("--json", "print JSON").action(selfHostedMigrationCommand("self-hosted-pull"));
|
|
13516
|
+
selfHosted.command("runner-register").description("register this machine as a self-hosted runner").requiredOption("--runner-id <id>", "stable runner id").option("--api-url <url>", "self-hosted control-plane API URL").option("--machine-id <id>", "OpenMachines machine id").option("--label <key=value>", "runner label; may be repeated or comma-separated", collectValues, []).option("--capability <key=json>", "runner capability; may be repeated or comma-separated", collectValues, []).option("--dry-run", "preview registration without posting").option("--apply", "post the registration to the control plane").option("--json", "print JSON").action(runAction(async (opts) => {
|
|
13517
|
+
if (opts.apply && opts.dryRun)
|
|
13518
|
+
throw new ValidationError("use either --apply or --dry-run, not both");
|
|
13519
|
+
const request = {
|
|
13520
|
+
apiUrl: opts.apiUrl,
|
|
13521
|
+
runnerId: opts.runnerId,
|
|
13522
|
+
machineId: opts.machineId,
|
|
13523
|
+
labels: parseStringMap(listFromRepeatedOpts(opts.label), "--label"),
|
|
13524
|
+
capabilities: parseJsonMap(listFromRepeatedOpts(opts.capability), "--capability")
|
|
13525
|
+
};
|
|
13526
|
+
const result = opts.apply ? await registerSelfHostedRunner(request) : { ok: true, dryRun: true, runner: request };
|
|
13527
|
+
if (isJson() || opts.json)
|
|
13528
|
+
console.log(JSON.stringify(result, null, 2));
|
|
13529
|
+
else
|
|
13530
|
+
console.log(`${opts.apply ? "registered" : "would register"} runner ${String(opts.runnerId)}`);
|
|
13531
|
+
}));
|
|
13022
13532
|
var cloud = program.command("cloud").description("inspect the hosted OpenLoops contract");
|
|
13023
13533
|
cloud.command("status").option("--json", "print JSON").action(runAction(deploymentStatusCommand("cloud")));
|
|
13024
13534
|
function formatTemplateVariable(template, name) {
|