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