@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/index.js
CHANGED
|
@@ -1523,7 +1523,7 @@ class Store {
|
|
|
1523
1523
|
}
|
|
1524
1524
|
const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
|
|
1525
1525
|
for (const migration of this.migrations()) {
|
|
1526
|
-
if (!migration.baseline &&
|
|
1526
|
+
if (!migration.baseline && applied.has(migration.id))
|
|
1527
1527
|
continue;
|
|
1528
1528
|
migration.apply();
|
|
1529
1529
|
if (!applied.has(migration.id)) {
|
|
@@ -1576,7 +1576,6 @@ class Store {
|
|
|
1576
1576
|
},
|
|
1577
1577
|
{
|
|
1578
1578
|
id: "0007_run_claim_tokens",
|
|
1579
|
-
reapply: true,
|
|
1580
1579
|
apply: () => {
|
|
1581
1580
|
this.addColumnIfMissing("loop_runs", "claim_token", "TEXT");
|
|
1582
1581
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_runs_claim_token ON loop_runs(claim_token) WHERE claim_token IS NOT NULL");
|
|
@@ -3841,6 +3840,190 @@ class Store {
|
|
|
3841
3840
|
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();
|
|
3842
3841
|
return row?.count ?? 0;
|
|
3843
3842
|
}
|
|
3843
|
+
exportMigrationRows(opts = {}) {
|
|
3844
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
3845
|
+
const workflows = this.db.query("SELECT * FROM workflow_specs ORDER BY created_at ASC, id ASC").all().map(rowToWorkflow);
|
|
3846
|
+
const loops = this.db.query("SELECT * FROM loops ORDER BY created_at ASC, id ASC").all().map(rowToLoop);
|
|
3847
|
+
const runs = includeRuns ? this.db.query("SELECT * FROM loop_runs ORDER BY created_at ASC, id ASC").all().map(rowToRun) : [];
|
|
3848
|
+
return { schemaVersion: SCHEMA_USER_VERSION, workflows, loops, runs, checks: this.migrationChecks() };
|
|
3849
|
+
}
|
|
3850
|
+
countTable(table) {
|
|
3851
|
+
const row = this.db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
3852
|
+
return row?.count ?? 0;
|
|
3853
|
+
}
|
|
3854
|
+
migrationChecks() {
|
|
3855
|
+
const now = nowIso();
|
|
3856
|
+
return {
|
|
3857
|
+
unsupportedCounts: {
|
|
3858
|
+
workflowInvocations: this.countTable("workflow_invocations"),
|
|
3859
|
+
workflowWorkItems: this.countTable("workflow_work_items"),
|
|
3860
|
+
workflowRuns: this.countTable("workflow_runs"),
|
|
3861
|
+
workflowStepRuns: this.countTable("workflow_step_runs"),
|
|
3862
|
+
workflowEvents: this.countTable("workflow_events"),
|
|
3863
|
+
goals: this.countTable("goals"),
|
|
3864
|
+
goalPlanNodes: this.countTable("goal_plan_nodes"),
|
|
3865
|
+
goalRuns: this.countTable("goal_runs")
|
|
3866
|
+
},
|
|
3867
|
+
volatileCounts: {
|
|
3868
|
+
daemonLeases: this.countTable("daemon_lease"),
|
|
3869
|
+
activeDaemonLeases: this.db.query("SELECT COUNT(*) AS count FROM daemon_lease WHERE expires_at > ?").get(now)?.count ?? 0,
|
|
3870
|
+
runningLoopRuns: this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3871
|
+
runningWorkflowRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3872
|
+
runningWorkflowStepRuns: this.db.query("SELECT COUNT(*) AS count FROM workflow_step_runs WHERE status = 'running'").get()?.count ?? 0,
|
|
3873
|
+
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
|
|
3874
|
+
}
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
upsertMigrationWorkflow(workflow, opts = {}) {
|
|
3878
|
+
const existing = this.getWorkflow(workflow.id);
|
|
3879
|
+
if (existing && !opts.replace)
|
|
3880
|
+
return existing;
|
|
3881
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, goal_json, steps_json, created_at, updated_at)
|
|
3882
|
+
VALUES ($id, $name, $description, $version, $status, $goal, $steps, $created, $updated)
|
|
3883
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3884
|
+
name=$name,
|
|
3885
|
+
description=$description,
|
|
3886
|
+
version=$version,
|
|
3887
|
+
status=$status,
|
|
3888
|
+
goal_json=$goal,
|
|
3889
|
+
steps_json=$steps,
|
|
3890
|
+
created_at=$created,
|
|
3891
|
+
updated_at=$updated`).run({
|
|
3892
|
+
$id: workflow.id,
|
|
3893
|
+
$name: workflow.name,
|
|
3894
|
+
$description: workflow.description ?? null,
|
|
3895
|
+
$version: workflow.version,
|
|
3896
|
+
$status: workflow.status,
|
|
3897
|
+
$goal: workflow.goal ? JSON.stringify(workflow.goal) : null,
|
|
3898
|
+
$steps: JSON.stringify(workflow.steps),
|
|
3899
|
+
$created: workflow.createdAt,
|
|
3900
|
+
$updated: workflow.updatedAt
|
|
3901
|
+
});
|
|
3902
|
+
const imported = this.getWorkflow(workflow.id);
|
|
3903
|
+
if (!imported)
|
|
3904
|
+
throw new Error(`workflow not found after migration import: ${workflow.id}`);
|
|
3905
|
+
return imported;
|
|
3906
|
+
}
|
|
3907
|
+
upsertMigrationLoop(loop, opts = {}) {
|
|
3908
|
+
const existing = this.getLoop(loop.id);
|
|
3909
|
+
if (existing && !opts.replace)
|
|
3910
|
+
return existing;
|
|
3911
|
+
this.assertNoNestedWorkflowGoal(loop.target, loop.goal);
|
|
3912
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, archived_at, archived_from_status, schedule_json, target_json,
|
|
3913
|
+
goal_json, machine_json, next_run_at, retry_scheduled_for, catch_up, catch_up_limit, overlap, max_attempts,
|
|
3914
|
+
retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
3915
|
+
VALUES ($id, $name, $description, $status, $archivedAt, $archivedFromStatus, $schedule, $target,
|
|
3916
|
+
$goal, $machine, $nextRun, $retrySlot, $catchUp, $catchUpLimit, $overlap, $maxAttempts,
|
|
3917
|
+
$retryDelay, $leaseMs, $expiresAt, $created, $updated)
|
|
3918
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3919
|
+
name=$name,
|
|
3920
|
+
description=$description,
|
|
3921
|
+
status=$status,
|
|
3922
|
+
archived_at=$archivedAt,
|
|
3923
|
+
archived_from_status=$archivedFromStatus,
|
|
3924
|
+
schedule_json=$schedule,
|
|
3925
|
+
target_json=$target,
|
|
3926
|
+
goal_json=$goal,
|
|
3927
|
+
machine_json=$machine,
|
|
3928
|
+
next_run_at=$nextRun,
|
|
3929
|
+
retry_scheduled_for=$retrySlot,
|
|
3930
|
+
catch_up=$catchUp,
|
|
3931
|
+
catch_up_limit=$catchUpLimit,
|
|
3932
|
+
overlap=$overlap,
|
|
3933
|
+
max_attempts=$maxAttempts,
|
|
3934
|
+
retry_delay_ms=$retryDelay,
|
|
3935
|
+
lease_ms=$leaseMs,
|
|
3936
|
+
expires_at=$expiresAt,
|
|
3937
|
+
created_at=$created,
|
|
3938
|
+
updated_at=$updated`).run({
|
|
3939
|
+
$id: loop.id,
|
|
3940
|
+
$name: loop.name,
|
|
3941
|
+
$description: loop.description ?? null,
|
|
3942
|
+
$status: loop.status,
|
|
3943
|
+
$archivedAt: loop.archivedAt ?? null,
|
|
3944
|
+
$archivedFromStatus: loop.archivedFromStatus ?? null,
|
|
3945
|
+
$schedule: JSON.stringify(loop.schedule),
|
|
3946
|
+
$target: JSON.stringify(loop.target),
|
|
3947
|
+
$goal: loop.goal ? JSON.stringify(loop.goal) : null,
|
|
3948
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
3949
|
+
$nextRun: loop.nextRunAt ?? null,
|
|
3950
|
+
$retrySlot: loop.retryScheduledFor ?? null,
|
|
3951
|
+
$catchUp: loop.catchUp,
|
|
3952
|
+
$catchUpLimit: loop.catchUpLimit,
|
|
3953
|
+
$overlap: loop.overlap,
|
|
3954
|
+
$maxAttempts: loop.maxAttempts,
|
|
3955
|
+
$retryDelay: loop.retryDelayMs,
|
|
3956
|
+
$leaseMs: loop.leaseMs,
|
|
3957
|
+
$expiresAt: loop.expiresAt ?? null,
|
|
3958
|
+
$created: loop.createdAt,
|
|
3959
|
+
$updated: loop.updatedAt
|
|
3960
|
+
});
|
|
3961
|
+
const imported = this.getLoop(loop.id);
|
|
3962
|
+
if (!imported)
|
|
3963
|
+
throw new Error(`loop not found after migration import: ${loop.id}`);
|
|
3964
|
+
return imported;
|
|
3965
|
+
}
|
|
3966
|
+
upsertMigrationRun(run, opts = {}) {
|
|
3967
|
+
if (run.status === "running")
|
|
3968
|
+
throw new ValidationError(`cannot import running run ${run.id}`);
|
|
3969
|
+
const existing = this.getRun(run.id);
|
|
3970
|
+
if (existing && !opts.replace)
|
|
3971
|
+
return existing;
|
|
3972
|
+
this.db.query(`INSERT INTO loop_runs (id, loop_id, loop_name, scheduled_for, attempt, status, started_at, finished_at,
|
|
3973
|
+
claimed_by, claim_token, lease_expires_at, pid, pgid, process_started_at, exit_code, duration_ms,
|
|
3974
|
+
stdout, stderr, error, goal_run_id, created_at, updated_at)
|
|
3975
|
+
VALUES ($id, $loopId, $loopName, $scheduledFor, $attempt, $status, $startedAt, $finishedAt,
|
|
3976
|
+
$claimedBy, NULL, $leaseExpiresAt, $pid, $pgid, $processStartedAt, $exitCode, $durationMs,
|
|
3977
|
+
$stdout, $stderr, $error, $goalRunId, $created, $updated)
|
|
3978
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
3979
|
+
loop_id=$loopId,
|
|
3980
|
+
loop_name=$loopName,
|
|
3981
|
+
scheduled_for=$scheduledFor,
|
|
3982
|
+
attempt=$attempt,
|
|
3983
|
+
status=$status,
|
|
3984
|
+
started_at=$startedAt,
|
|
3985
|
+
finished_at=$finishedAt,
|
|
3986
|
+
claimed_by=$claimedBy,
|
|
3987
|
+
claim_token=NULL,
|
|
3988
|
+
lease_expires_at=$leaseExpiresAt,
|
|
3989
|
+
pid=$pid,
|
|
3990
|
+
pgid=$pgid,
|
|
3991
|
+
process_started_at=$processStartedAt,
|
|
3992
|
+
exit_code=$exitCode,
|
|
3993
|
+
duration_ms=$durationMs,
|
|
3994
|
+
stdout=$stdout,
|
|
3995
|
+
stderr=$stderr,
|
|
3996
|
+
error=$error,
|
|
3997
|
+
goal_run_id=$goalRunId,
|
|
3998
|
+
created_at=$created,
|
|
3999
|
+
updated_at=$updated`).run({
|
|
4000
|
+
$id: run.id,
|
|
4001
|
+
$loopId: run.loopId,
|
|
4002
|
+
$loopName: run.loopName,
|
|
4003
|
+
$scheduledFor: run.scheduledFor,
|
|
4004
|
+
$attempt: run.attempt,
|
|
4005
|
+
$status: run.status,
|
|
4006
|
+
$startedAt: run.startedAt ?? null,
|
|
4007
|
+
$finishedAt: run.finishedAt ?? null,
|
|
4008
|
+
$claimedBy: run.claimedBy ?? null,
|
|
4009
|
+
$leaseExpiresAt: run.leaseExpiresAt ?? null,
|
|
4010
|
+
$pid: run.pid ?? null,
|
|
4011
|
+
$pgid: run.pgid ?? null,
|
|
4012
|
+
$processStartedAt: run.processStartedAt ?? null,
|
|
4013
|
+
$exitCode: run.exitCode ?? null,
|
|
4014
|
+
$durationMs: run.durationMs ?? null,
|
|
4015
|
+
$stdout: scrubbedOrNull(run.stdout),
|
|
4016
|
+
$stderr: scrubbedOrNull(run.stderr),
|
|
4017
|
+
$error: scrubbedOrNull(run.error),
|
|
4018
|
+
$goalRunId: run.goalRunId ?? null,
|
|
4019
|
+
$created: run.createdAt,
|
|
4020
|
+
$updated: run.updatedAt
|
|
4021
|
+
});
|
|
4022
|
+
const imported = this.getRun(run.id);
|
|
4023
|
+
if (!imported)
|
|
4024
|
+
throw new Error(`run not found after migration import: ${run.id}`);
|
|
4025
|
+
return imported;
|
|
4026
|
+
}
|
|
3844
4027
|
pruneHistory(opts) {
|
|
3845
4028
|
const { maxAgeDays, keepPerLoop } = opts;
|
|
3846
4029
|
if (maxAgeDays === undefined && keepPerLoop === undefined) {
|
|
@@ -3982,7 +4165,7 @@ class Store {
|
|
|
3982
4165
|
// package.json
|
|
3983
4166
|
var package_default = {
|
|
3984
4167
|
name: "@hasna/loops",
|
|
3985
|
-
version: "0.4.
|
|
4168
|
+
version: "0.4.5",
|
|
3986
4169
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
3987
4170
|
type: "module",
|
|
3988
4171
|
main: "dist/index.js",
|
|
@@ -4056,7 +4239,7 @@ var package_default = {
|
|
|
4056
4239
|
prepare: "test -d dist || bun run build",
|
|
4057
4240
|
"dev:cli": "bun run src/cli/index.ts",
|
|
4058
4241
|
"dev:daemon": "bun run src/daemon/index.ts",
|
|
4059
|
-
prepublishOnly: "bun run build && bun run test:boundary"
|
|
4242
|
+
prepublishOnly: "bun run build && bun run typecheck && bun test && bun run test:boundary"
|
|
4060
4243
|
},
|
|
4061
4244
|
keywords: [
|
|
4062
4245
|
"loops",
|
|
@@ -8231,6 +8414,515 @@ if (import.meta.main) {
|
|
|
8231
8414
|
});
|
|
8232
8415
|
}
|
|
8233
8416
|
|
|
8417
|
+
// src/lib/migration.ts
|
|
8418
|
+
import { createHash as createHash3 } from "crypto";
|
|
8419
|
+
import { hostname as hostname2 } from "os";
|
|
8420
|
+
var LOOPS_MIGRATION_SCHEMA = "open-loops.migration/v1";
|
|
8421
|
+
function canonicalize(value) {
|
|
8422
|
+
if (Array.isArray(value))
|
|
8423
|
+
return value.map((entry) => canonicalize(entry));
|
|
8424
|
+
if (value && typeof value === "object") {
|
|
8425
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, canonicalize(entry)]));
|
|
8426
|
+
}
|
|
8427
|
+
return value;
|
|
8428
|
+
}
|
|
8429
|
+
function migrationHash(value) {
|
|
8430
|
+
return createHash3("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
|
8431
|
+
}
|
|
8432
|
+
function pushBlocker(rows, resource, id, reason, name) {
|
|
8433
|
+
rows.push({ resource, id, name, action: "blocked", reason });
|
|
8434
|
+
}
|
|
8435
|
+
function checksToBlockers(checks, prefix, warnings = []) {
|
|
8436
|
+
const rows = [];
|
|
8437
|
+
for (const [name, count] of Object.entries(checks.unsupportedCounts)) {
|
|
8438
|
+
if (count > 0) {
|
|
8439
|
+
pushBlocker(rows, "remote", `${prefix}:unsupported:${name}`, `${prefix} ${name} has ${count} rows; this migration bundle does not preserve that table yet`);
|
|
8440
|
+
}
|
|
8441
|
+
}
|
|
8442
|
+
for (const [name, count] of Object.entries(checks.volatileCounts)) {
|
|
8443
|
+
if (name === "daemonLeases") {
|
|
8444
|
+
if (count > 0)
|
|
8445
|
+
warnings.push(`${prefix} daemon_lease has ${count} volatile rows; they are intentionally not exported as authority`);
|
|
8446
|
+
continue;
|
|
8447
|
+
}
|
|
8448
|
+
if (count > 0) {
|
|
8449
|
+
pushBlocker(rows, "remote", `${prefix}:volatile:${name}`, `${prefix} ${name} has ${count} rows; stop or finish active work before no-loss migration`);
|
|
8450
|
+
}
|
|
8451
|
+
}
|
|
8452
|
+
return rows;
|
|
8453
|
+
}
|
|
8454
|
+
function sanitizeCommandEnv(value, blockers, resource, id, name) {
|
|
8455
|
+
const copy = structuredClone(value);
|
|
8456
|
+
const target = copy.target;
|
|
8457
|
+
if (target && typeof target === "object" && !Array.isArray(target) && target.type === "command") {
|
|
8458
|
+
const commandTarget = target;
|
|
8459
|
+
if (commandTarget.env && Object.keys(commandTarget.env).length > 0) {
|
|
8460
|
+
commandTarget.env = Object.fromEntries(Object.keys(commandTarget.env).map((key) => [key, "[redacted]"]));
|
|
8461
|
+
pushBlocker(blockers, resource, id, "command target env values are redacted and cannot be imported as a no-loss row", name);
|
|
8462
|
+
}
|
|
8463
|
+
}
|
|
8464
|
+
return copy;
|
|
8465
|
+
}
|
|
8466
|
+
function sanitizeWorkflow(workflow, blockers) {
|
|
8467
|
+
const copy = structuredClone(workflow);
|
|
8468
|
+
copy.steps = copy.steps.map((step) => {
|
|
8469
|
+
if (step.target.type !== "command" || !step.target.env || Object.keys(step.target.env).length === 0)
|
|
8470
|
+
return step;
|
|
8471
|
+
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);
|
|
8472
|
+
return {
|
|
8473
|
+
...step,
|
|
8474
|
+
target: {
|
|
8475
|
+
...step.target,
|
|
8476
|
+
env: Object.fromEntries(Object.keys(step.target.env).map((key) => [key, "[redacted]"]))
|
|
8477
|
+
}
|
|
8478
|
+
};
|
|
8479
|
+
});
|
|
8480
|
+
return copy;
|
|
8481
|
+
}
|
|
8482
|
+
function sanitizeExportRows(rows) {
|
|
8483
|
+
const blockers = [];
|
|
8484
|
+
const warnings = [];
|
|
8485
|
+
blockers.push(...checksToBlockers(rows.checks, "source", warnings));
|
|
8486
|
+
const workflows = rows.workflows.map((workflow) => sanitizeWorkflow(workflow, blockers));
|
|
8487
|
+
const loops = rows.loops.map((loop) => sanitizeCommandEnv(loop, blockers, "loop", loop.id, loop.name));
|
|
8488
|
+
const beforeScrub = JSON.stringify({ workflows, loops, runs: rows.runs });
|
|
8489
|
+
const data = scrubSecretsDeep({ workflows, loops, runs: rows.runs });
|
|
8490
|
+
if (JSON.stringify(data) !== beforeScrub) {
|
|
8491
|
+
warnings.push("secret-looking strings were scrubbed from the export bundle; treat this as non-importable unless every scrub is expected");
|
|
8492
|
+
pushBlocker(blockers, "remote", "secret-scrub", "secret-looking strings were scrubbed from the export bundle");
|
|
8493
|
+
}
|
|
8494
|
+
return { data, blockers, warnings };
|
|
8495
|
+
}
|
|
8496
|
+
function exportLoopsMigrationBundle(store, opts = {}) {
|
|
8497
|
+
const rows = store.exportMigrationRows({ includeRuns: opts.includeRuns ?? true });
|
|
8498
|
+
const sanitized = sanitizeExportRows(rows);
|
|
8499
|
+
const bundleBody = {
|
|
8500
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8501
|
+
packageVersion: packageVersion(),
|
|
8502
|
+
exportedAt: new Date().toISOString(),
|
|
8503
|
+
source: {
|
|
8504
|
+
backend: "sqlite",
|
|
8505
|
+
schemaVersion: rows.schemaVersion,
|
|
8506
|
+
hostname: hostname2()
|
|
8507
|
+
},
|
|
8508
|
+
checks: rows.checks,
|
|
8509
|
+
importable: sanitized.blockers.length === 0,
|
|
8510
|
+
counts: {
|
|
8511
|
+
workflows: sanitized.data.workflows.length,
|
|
8512
|
+
loops: sanitized.data.loops.length,
|
|
8513
|
+
runs: sanitized.data.runs.length
|
|
8514
|
+
},
|
|
8515
|
+
data: sanitized.data,
|
|
8516
|
+
blockers: sanitized.blockers,
|
|
8517
|
+
warnings: sanitized.warnings
|
|
8518
|
+
};
|
|
8519
|
+
return {
|
|
8520
|
+
...bundleBody,
|
|
8521
|
+
hash: migrationHash(bundleBody)
|
|
8522
|
+
};
|
|
8523
|
+
}
|
|
8524
|
+
function validateLoopsMigrationBundle(value) {
|
|
8525
|
+
if (!value || typeof value !== "object")
|
|
8526
|
+
throw new ValidationError("migration bundle must be a JSON object");
|
|
8527
|
+
const bundle = value;
|
|
8528
|
+
if (bundle.schema !== LOOPS_MIGRATION_SCHEMA)
|
|
8529
|
+
throw new ValidationError(`unsupported migration bundle schema: ${String(bundle.schema)}`);
|
|
8530
|
+
if (!bundle.data || !Array.isArray(bundle.data.workflows) || !Array.isArray(bundle.data.loops) || !Array.isArray(bundle.data.runs)) {
|
|
8531
|
+
throw new ValidationError("migration bundle data must include workflows, loops, and runs arrays");
|
|
8532
|
+
}
|
|
8533
|
+
if (!bundle.checks || !bundle.counts || !bundle.source || !bundle.hash)
|
|
8534
|
+
throw new ValidationError("migration bundle is missing required metadata");
|
|
8535
|
+
const typed = bundle;
|
|
8536
|
+
assertMigrationBundleIntegrity(typed);
|
|
8537
|
+
return typed;
|
|
8538
|
+
}
|
|
8539
|
+
function assertMigrationBundleIntegrity(bundle) {
|
|
8540
|
+
const { hash: _hash, ...body } = bundle;
|
|
8541
|
+
const expectedHash = migrationHash(body);
|
|
8542
|
+
if (bundle.hash !== expectedHash)
|
|
8543
|
+
throw new ValidationError("migration bundle hash mismatch");
|
|
8544
|
+
}
|
|
8545
|
+
function rowPlanSummary(plan) {
|
|
8546
|
+
const count = (action) => plan.rows.filter((row) => row.action === action).length;
|
|
8547
|
+
return {
|
|
8548
|
+
dryRun: plan.dryRun,
|
|
8549
|
+
replace: plan.replace,
|
|
8550
|
+
importable: plan.importable,
|
|
8551
|
+
workflows: plan.rows.filter((row) => row.resource === "workflow").length,
|
|
8552
|
+
loops: plan.rows.filter((row) => row.resource === "loop").length,
|
|
8553
|
+
runs: plan.rows.filter((row) => row.resource === "run").length,
|
|
8554
|
+
insert: count("insert"),
|
|
8555
|
+
update: count("update"),
|
|
8556
|
+
skip: count("skip"),
|
|
8557
|
+
conflict: count("conflict"),
|
|
8558
|
+
blocked: count("blocked")
|
|
8559
|
+
};
|
|
8560
|
+
}
|
|
8561
|
+
function finalizePlan(plan) {
|
|
8562
|
+
return { ...plan, summary: rowPlanSummary(plan) };
|
|
8563
|
+
}
|
|
8564
|
+
function compareResource(current, incoming, opts) {
|
|
8565
|
+
const incomingHash = migrationHash(incoming);
|
|
8566
|
+
if (!current)
|
|
8567
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "insert", incomingHash };
|
|
8568
|
+
const currentHash = migrationHash(current);
|
|
8569
|
+
if (currentHash === incomingHash)
|
|
8570
|
+
return { resource: opts.resource, id: opts.id, name: opts.name, action: "skip", incomingHash, currentHash };
|
|
8571
|
+
return {
|
|
8572
|
+
resource: opts.resource,
|
|
8573
|
+
id: opts.id,
|
|
8574
|
+
name: opts.name,
|
|
8575
|
+
action: opts.replace ? "update" : "conflict",
|
|
8576
|
+
reason: opts.replace ? "existing row differs and --replace was requested" : "existing row differs; rerun with --replace to update",
|
|
8577
|
+
incomingHash,
|
|
8578
|
+
currentHash
|
|
8579
|
+
};
|
|
8580
|
+
}
|
|
8581
|
+
function buildImportMigrationPlan(store, bundle, opts = {}) {
|
|
8582
|
+
assertMigrationBundleIntegrity(bundle);
|
|
8583
|
+
const includeRuns = opts.includeRuns ?? true;
|
|
8584
|
+
const replace = opts.replace ?? false;
|
|
8585
|
+
const rows = [];
|
|
8586
|
+
const warnings = [...bundle.warnings ?? []];
|
|
8587
|
+
rows.push(...checksToBlockers(store.exportMigrationRows({ includeRuns: false }).checks, "destination", warnings));
|
|
8588
|
+
if (!bundle.importable || bundle.blockers.length > 0) {
|
|
8589
|
+
rows.push(...bundle.blockers.map((row) => ({ ...row, action: "blocked" })));
|
|
8590
|
+
}
|
|
8591
|
+
const workflowIds = new Set(bundle.data.workflows.map((workflow) => workflow.id));
|
|
8592
|
+
const loopIds = new Set(bundle.data.loops.map((loop) => loop.id));
|
|
8593
|
+
for (const workflow of bundle.data.workflows) {
|
|
8594
|
+
const redactedStep = workflow.steps.find((step) => step.target.type === "command" && step.target.env && Object.values(step.target.env).includes("[redacted]"));
|
|
8595
|
+
if (redactedStep) {
|
|
8596
|
+
rows.push({
|
|
8597
|
+
resource: "workflow",
|
|
8598
|
+
id: workflow.id,
|
|
8599
|
+
name: workflow.name,
|
|
8600
|
+
action: "blocked",
|
|
8601
|
+
reason: `workflow step ${redactedStep.id} has redacted command env values`,
|
|
8602
|
+
incomingHash: migrationHash(workflow)
|
|
8603
|
+
});
|
|
8604
|
+
continue;
|
|
8605
|
+
}
|
|
8606
|
+
const activeNameCollision = store.listWorkflows({ status: "active" }).find((current) => current.name === workflow.name && current.id !== workflow.id);
|
|
8607
|
+
if (workflow.status === "active" && activeNameCollision) {
|
|
8608
|
+
rows.push({
|
|
8609
|
+
resource: "workflow",
|
|
8610
|
+
id: workflow.id,
|
|
8611
|
+
name: workflow.name,
|
|
8612
|
+
action: "conflict",
|
|
8613
|
+
reason: `active workflow name collides with existing workflow ${activeNameCollision.id}`,
|
|
8614
|
+
incomingHash: migrationHash(workflow),
|
|
8615
|
+
currentHash: migrationHash(activeNameCollision)
|
|
8616
|
+
});
|
|
8617
|
+
continue;
|
|
8618
|
+
}
|
|
8619
|
+
rows.push(compareResource(store.getWorkflow(workflow.id), workflow, { replace, resource: "workflow", id: workflow.id, name: workflow.name }));
|
|
8620
|
+
}
|
|
8621
|
+
for (const loop of bundle.data.loops) {
|
|
8622
|
+
if (loop.target.type === "command" && loop.target.env && Object.values(loop.target.env).includes("[redacted]")) {
|
|
8623
|
+
rows.push({
|
|
8624
|
+
resource: "loop",
|
|
8625
|
+
id: loop.id,
|
|
8626
|
+
name: loop.name,
|
|
8627
|
+
action: "blocked",
|
|
8628
|
+
reason: "loop has redacted command env values",
|
|
8629
|
+
incomingHash: migrationHash(loop)
|
|
8630
|
+
});
|
|
8631
|
+
continue;
|
|
8632
|
+
}
|
|
8633
|
+
if (loop.target.type === "workflow" && !workflowIds.has(loop.target.workflowId) && !store.getWorkflow(loop.target.workflowId)) {
|
|
8634
|
+
rows.push({
|
|
8635
|
+
resource: "loop",
|
|
8636
|
+
id: loop.id,
|
|
8637
|
+
name: loop.name,
|
|
8638
|
+
action: "blocked",
|
|
8639
|
+
reason: `workflow target ${loop.target.workflowId} is not present in bundle or destination store`,
|
|
8640
|
+
incomingHash: migrationHash(loop)
|
|
8641
|
+
});
|
|
8642
|
+
continue;
|
|
8643
|
+
}
|
|
8644
|
+
rows.push(compareResource(store.getLoop(loop.id), loop, { replace, resource: "loop", id: loop.id, name: loop.name }));
|
|
8645
|
+
}
|
|
8646
|
+
if (includeRuns) {
|
|
8647
|
+
for (const run of bundle.data.runs) {
|
|
8648
|
+
if (run.status === "running") {
|
|
8649
|
+
rows.push({
|
|
8650
|
+
resource: "run",
|
|
8651
|
+
id: run.id,
|
|
8652
|
+
name: run.loopName,
|
|
8653
|
+
action: "blocked",
|
|
8654
|
+
reason: "running rows carry volatile lease/process ownership and must finish before import",
|
|
8655
|
+
incomingHash: migrationHash(run)
|
|
8656
|
+
});
|
|
8657
|
+
continue;
|
|
8658
|
+
}
|
|
8659
|
+
if (!loopIds.has(run.loopId) && !store.getLoop(run.loopId)) {
|
|
8660
|
+
rows.push({
|
|
8661
|
+
resource: "run",
|
|
8662
|
+
id: run.id,
|
|
8663
|
+
name: run.loopName,
|
|
8664
|
+
action: "blocked",
|
|
8665
|
+
reason: `loop ${run.loopId} is not present in bundle or destination store`,
|
|
8666
|
+
incomingHash: migrationHash(run)
|
|
8667
|
+
});
|
|
8668
|
+
continue;
|
|
8669
|
+
}
|
|
8670
|
+
const slot = store.getRunBySlot(run.loopId, run.scheduledFor);
|
|
8671
|
+
if (slot && slot.id !== run.id) {
|
|
8672
|
+
rows.push({
|
|
8673
|
+
resource: "run",
|
|
8674
|
+
id: run.id,
|
|
8675
|
+
name: run.loopName,
|
|
8676
|
+
action: "conflict",
|
|
8677
|
+
reason: `scheduled slot already belongs to run ${slot.id}`,
|
|
8678
|
+
incomingHash: migrationHash(run),
|
|
8679
|
+
currentHash: migrationHash(slot)
|
|
8680
|
+
});
|
|
8681
|
+
continue;
|
|
8682
|
+
}
|
|
8683
|
+
rows.push(compareResource(store.getRun(run.id), run, { replace, resource: "run", id: run.id, name: run.loopName }));
|
|
8684
|
+
}
|
|
8685
|
+
} else if (bundle.data.runs.length > 0) {
|
|
8686
|
+
warnings.push("run history is present in the bundle but --no-runs was requested");
|
|
8687
|
+
}
|
|
8688
|
+
const plan = finalizePlan({
|
|
8689
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8690
|
+
operation: "import",
|
|
8691
|
+
dryRun: opts.dryRun ?? true,
|
|
8692
|
+
replace,
|
|
8693
|
+
importable: bundle.importable && rows.every((row) => row.action !== "blocked" && row.action !== "conflict"),
|
|
8694
|
+
rows,
|
|
8695
|
+
warnings
|
|
8696
|
+
});
|
|
8697
|
+
return { ...plan, importable: plan.summary.blocked === 0 && plan.summary.conflict === 0 };
|
|
8698
|
+
}
|
|
8699
|
+
function applyImportMigrationBundle(store, bundle, opts = {}) {
|
|
8700
|
+
const plan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
8701
|
+
if (plan.summary.blocked > 0 || plan.summary.conflict > 0 || !plan.importable) {
|
|
8702
|
+
throw new ValidationError(`migration import is not safe to apply: blocked=${plan.summary.blocked} conflict=${plan.summary.conflict}`);
|
|
8703
|
+
}
|
|
8704
|
+
const applied = { workflows: 0, loops: 0, runs: 0 };
|
|
8705
|
+
let appliedPlan = plan;
|
|
8706
|
+
store.writeTransaction(() => {
|
|
8707
|
+
appliedPlan = buildImportMigrationPlan(store, bundle, { ...opts, dryRun: false });
|
|
8708
|
+
if (appliedPlan.summary.blocked > 0 || appliedPlan.summary.conflict > 0 || !appliedPlan.importable) {
|
|
8709
|
+
throw new ValidationError(`destination store changed before import apply: blocked=${appliedPlan.summary.blocked} conflict=${appliedPlan.summary.conflict}`);
|
|
8710
|
+
}
|
|
8711
|
+
for (const workflow of bundle.data.workflows) {
|
|
8712
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "workflow" && entry.id === workflow.id);
|
|
8713
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8714
|
+
store.upsertMigrationWorkflow(workflow, { replace: opts.replace });
|
|
8715
|
+
applied.workflows += 1;
|
|
8716
|
+
}
|
|
8717
|
+
}
|
|
8718
|
+
for (const loop of bundle.data.loops) {
|
|
8719
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "loop" && entry.id === loop.id);
|
|
8720
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8721
|
+
store.upsertMigrationLoop(loop, { replace: opts.replace });
|
|
8722
|
+
applied.loops += 1;
|
|
8723
|
+
}
|
|
8724
|
+
}
|
|
8725
|
+
if (opts.includeRuns ?? true) {
|
|
8726
|
+
for (const run of bundle.data.runs) {
|
|
8727
|
+
const row = appliedPlan.rows.find((entry) => entry.resource === "run" && entry.id === run.id);
|
|
8728
|
+
if (row?.action === "insert" || row?.action === "update") {
|
|
8729
|
+
store.upsertMigrationRun(run, { replace: opts.replace });
|
|
8730
|
+
applied.runs += 1;
|
|
8731
|
+
}
|
|
8732
|
+
}
|
|
8733
|
+
}
|
|
8734
|
+
});
|
|
8735
|
+
return { plan: appliedPlan, applied };
|
|
8736
|
+
}
|
|
8737
|
+
function envValue2(env, keys) {
|
|
8738
|
+
for (const key of keys) {
|
|
8739
|
+
const value = env[key]?.trim();
|
|
8740
|
+
if (value)
|
|
8741
|
+
return value;
|
|
8742
|
+
}
|
|
8743
|
+
return;
|
|
8744
|
+
}
|
|
8745
|
+
function resolveApiConfig(opts) {
|
|
8746
|
+
const env = opts.env ?? process.env;
|
|
8747
|
+
return {
|
|
8748
|
+
apiUrl: opts.apiUrl ?? envValue2(env, ["LOOPS_API_URL", "HASNA_LOOPS_API_URL", "LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"]),
|
|
8749
|
+
token: opts.apiToken ?? envValue2(env, ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN", "LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"])
|
|
8750
|
+
};
|
|
8751
|
+
}
|
|
8752
|
+
function isLocalApiUrl(value) {
|
|
8753
|
+
try {
|
|
8754
|
+
return ["127.0.0.1", "localhost", "::1"].includes(new URL(value).hostname);
|
|
8755
|
+
} catch {
|
|
8756
|
+
return false;
|
|
8757
|
+
}
|
|
8758
|
+
}
|
|
8759
|
+
function endpoint(base, path) {
|
|
8760
|
+
return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
|
|
8761
|
+
}
|
|
8762
|
+
async function requestJson(fetchImpl, config, path, init = {}) {
|
|
8763
|
+
const response = await fetchImpl(endpoint(config.apiUrl, path), {
|
|
8764
|
+
...init,
|
|
8765
|
+
headers: {
|
|
8766
|
+
...init.body ? { "content-type": "application/json" } : {},
|
|
8767
|
+
...config.token ? { authorization: `Bearer ${config.token}` } : {},
|
|
8768
|
+
...init.headers
|
|
8769
|
+
}
|
|
8770
|
+
});
|
|
8771
|
+
const payload = await response.json().catch(() => ({}));
|
|
8772
|
+
if (!response.ok)
|
|
8773
|
+
throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
|
|
8774
|
+
return payload;
|
|
8775
|
+
}
|
|
8776
|
+
async function fetchRemotePreview(opts) {
|
|
8777
|
+
const config = resolveApiConfig(opts);
|
|
8778
|
+
const warnings = [];
|
|
8779
|
+
if (!config.apiUrl) {
|
|
8780
|
+
warnings.push("LOOPS_API_URL or HASNA_LOOPS_API_URL is required to inspect a self-hosted control plane");
|
|
8781
|
+
return { loops: [], runs: [], warnings };
|
|
8782
|
+
}
|
|
8783
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token) {
|
|
8784
|
+
warnings.push("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
8785
|
+
return { loops: [], runs: [], warnings };
|
|
8786
|
+
}
|
|
8787
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
8788
|
+
const loopsPayload = await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/loops?includeArchived=true&limit=1000");
|
|
8789
|
+
const runsPayload = opts.includeRuns === false ? { runs: [] } : await requestJson(fetchImpl, { apiUrl: config.apiUrl, token: config.token }, "/v1/runs?limit=1000");
|
|
8790
|
+
return {
|
|
8791
|
+
loops: Array.isArray(loopsPayload.loops) ? loopsPayload.loops : [],
|
|
8792
|
+
runs: Array.isArray(runsPayload.runs) ? runsPayload.runs : [],
|
|
8793
|
+
warnings
|
|
8794
|
+
};
|
|
8795
|
+
}
|
|
8796
|
+
async function buildSelfHostedMigrationPlan(store, opts) {
|
|
8797
|
+
const bundle = exportLoopsMigrationBundle(store, { includeRuns: opts.includeRuns ?? true });
|
|
8798
|
+
const remote = await fetchRemotePreview(opts);
|
|
8799
|
+
const rows = [...bundle.blockers.map((row) => ({ ...row, action: "blocked" }))];
|
|
8800
|
+
const warnings = [
|
|
8801
|
+
...bundle.warnings,
|
|
8802
|
+
...remote.warnings,
|
|
8803
|
+
"self-hosted remote apply is preview-only until the control plane exposes id-preserving workflow/loop/run import endpoints"
|
|
8804
|
+
];
|
|
8805
|
+
if (opts.operation === "self-hosted-pull") {
|
|
8806
|
+
for (const entry of remote.loops) {
|
|
8807
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
8808
|
+
const id = typeof value.id === "string" ? value.id : `remote-loop:${rows.length}`;
|
|
8809
|
+
rows.push({
|
|
8810
|
+
resource: "loop",
|
|
8811
|
+
id,
|
|
8812
|
+
name: typeof value.name === "string" ? value.name : undefined,
|
|
8813
|
+
action: "blocked",
|
|
8814
|
+
reason: "remote loop pull needs a full id-preserving export/import endpoint; /v1/loops returns public redacted rows only",
|
|
8815
|
+
currentHash: migrationHash(entry)
|
|
8816
|
+
});
|
|
8817
|
+
}
|
|
8818
|
+
for (const entry of remote.runs) {
|
|
8819
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
8820
|
+
const id = typeof value.id === "string" ? value.id : `remote-run:${rows.length}`;
|
|
8821
|
+
rows.push({
|
|
8822
|
+
resource: "run",
|
|
8823
|
+
id,
|
|
8824
|
+
name: typeof value.loopName === "string" ? value.loopName : undefined,
|
|
8825
|
+
action: "blocked",
|
|
8826
|
+
reason: "remote run-history pull needs an id-preserving export/import endpoint; /v1/runs returns public redacted rows only",
|
|
8827
|
+
currentHash: migrationHash(entry)
|
|
8828
|
+
});
|
|
8829
|
+
}
|
|
8830
|
+
const plan2 = finalizePlan({
|
|
8831
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8832
|
+
operation: opts.operation,
|
|
8833
|
+
dryRun: true,
|
|
8834
|
+
replace: false,
|
|
8835
|
+
importable: false,
|
|
8836
|
+
rows,
|
|
8837
|
+
warnings
|
|
8838
|
+
});
|
|
8839
|
+
return { ...plan2, importable: false };
|
|
8840
|
+
}
|
|
8841
|
+
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]));
|
|
8842
|
+
for (const workflow of bundle.data.workflows) {
|
|
8843
|
+
rows.push({
|
|
8844
|
+
resource: "workflow",
|
|
8845
|
+
id: workflow.id,
|
|
8846
|
+
name: workflow.name,
|
|
8847
|
+
action: "blocked",
|
|
8848
|
+
reason: "self-hosted API does not expose id-preserving workflow import/list endpoints yet",
|
|
8849
|
+
incomingHash: migrationHash(workflow)
|
|
8850
|
+
});
|
|
8851
|
+
}
|
|
8852
|
+
for (const loop of bundle.data.loops) {
|
|
8853
|
+
const remoteLoop = remoteLoopsByName.get(loop.name);
|
|
8854
|
+
rows.push({
|
|
8855
|
+
resource: "loop",
|
|
8856
|
+
id: loop.id,
|
|
8857
|
+
name: loop.name,
|
|
8858
|
+
action: "blocked",
|
|
8859
|
+
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",
|
|
8860
|
+
incomingHash: migrationHash(loop),
|
|
8861
|
+
currentHash: remoteLoop ? migrationHash(remoteLoop) : undefined
|
|
8862
|
+
});
|
|
8863
|
+
}
|
|
8864
|
+
if (opts.includeRuns ?? true) {
|
|
8865
|
+
for (const run of bundle.data.runs) {
|
|
8866
|
+
rows.push({
|
|
8867
|
+
resource: "run",
|
|
8868
|
+
id: run.id,
|
|
8869
|
+
name: run.loopName,
|
|
8870
|
+
action: "blocked",
|
|
8871
|
+
reason: "self-hosted API does not expose run-history import endpoints; remote runners should create new run rows",
|
|
8872
|
+
incomingHash: migrationHash(run)
|
|
8873
|
+
});
|
|
8874
|
+
}
|
|
8875
|
+
}
|
|
8876
|
+
const plan = finalizePlan({
|
|
8877
|
+
schema: LOOPS_MIGRATION_SCHEMA,
|
|
8878
|
+
operation: opts.operation,
|
|
8879
|
+
dryRun: true,
|
|
8880
|
+
replace: false,
|
|
8881
|
+
importable: false,
|
|
8882
|
+
rows,
|
|
8883
|
+
warnings
|
|
8884
|
+
});
|
|
8885
|
+
return { ...plan, importable: false };
|
|
8886
|
+
}
|
|
8887
|
+
async function registerSelfHostedRunner(opts) {
|
|
8888
|
+
const config = resolveApiConfig(opts);
|
|
8889
|
+
if (!config.apiUrl)
|
|
8890
|
+
throw new ValidationError("LOOPS_API_URL or --api-url is required");
|
|
8891
|
+
if (!isLocalApiUrl(config.apiUrl) && !config.token)
|
|
8892
|
+
throw new ValidationError("non-local self-hosted APIs require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
8893
|
+
const payload = await requestJson(opts.fetchImpl ?? fetch, { apiUrl: config.apiUrl, token: config.token }, "/v1/runners/register", {
|
|
8894
|
+
method: "POST",
|
|
8895
|
+
body: JSON.stringify({
|
|
8896
|
+
runnerId: opts.runnerId,
|
|
8897
|
+
machineId: opts.machineId,
|
|
8898
|
+
labels: opts.labels ?? {},
|
|
8899
|
+
capabilities: opts.capabilities ?? {}
|
|
8900
|
+
})
|
|
8901
|
+
});
|
|
8902
|
+
return {
|
|
8903
|
+
ok: payload.ok === true,
|
|
8904
|
+
runner: payload.runner && typeof payload.runner === "object" ? payload.runner : {}
|
|
8905
|
+
};
|
|
8906
|
+
}
|
|
8907
|
+
function publicMigrationBundle(bundle) {
|
|
8908
|
+
return {
|
|
8909
|
+
...bundle,
|
|
8910
|
+
data: {
|
|
8911
|
+
workflows: bundle.data.workflows.map(publicWorkflow),
|
|
8912
|
+
loops: bundle.data.loops.map(publicLoop),
|
|
8913
|
+
runs: bundle.data.runs.map((run) => publicRun(run, false, { redactError: true }))
|
|
8914
|
+
}
|
|
8915
|
+
};
|
|
8916
|
+
}
|
|
8917
|
+
function selfHostedControlPlaneSummary(env = process.env) {
|
|
8918
|
+
const config = loopControlPlaneConfig(env);
|
|
8919
|
+
return {
|
|
8920
|
+
apiUrl: config.apiUrl,
|
|
8921
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
8922
|
+
authTokenPresent: config.apiAuthTokenPresent
|
|
8923
|
+
};
|
|
8924
|
+
}
|
|
8925
|
+
|
|
8234
8926
|
// src/sdk/index.ts
|
|
8235
8927
|
class LoopsClient {
|
|
8236
8928
|
store;
|
|
@@ -8306,6 +8998,18 @@ class LoopsClient {
|
|
|
8306
8998
|
const result = await runLoopNow({ store: this.store, idOrName, runnerId: this.runnerId });
|
|
8307
8999
|
return result.run;
|
|
8308
9000
|
}
|
|
9001
|
+
exportBundle(opts = {}) {
|
|
9002
|
+
return exportLoopsMigrationBundle(this.store, opts);
|
|
9003
|
+
}
|
|
9004
|
+
planImport(bundle, opts = {}) {
|
|
9005
|
+
return buildImportMigrationPlan(this.store, bundle, opts);
|
|
9006
|
+
}
|
|
9007
|
+
importBundle(bundle, opts = {}) {
|
|
9008
|
+
return applyImportMigrationBundle(this.store, bundle, opts);
|
|
9009
|
+
}
|
|
9010
|
+
planSelfHostedMigration(opts = {}) {
|
|
9011
|
+
return buildSelfHostedMigrationPlan(this.store, { ...opts, operation: opts.operation ?? "self-hosted-migrate" });
|
|
9012
|
+
}
|
|
8309
9013
|
close() {
|
|
8310
9014
|
if (this.ownStore)
|
|
8311
9015
|
this.store.close();
|
|
@@ -8557,12 +9261,12 @@ function createSqliteLoopStorage(path) {
|
|
|
8557
9261
|
}
|
|
8558
9262
|
|
|
8559
9263
|
// src/lib/storage/postgres-schema.ts
|
|
8560
|
-
import { createHash as
|
|
9264
|
+
import { createHash as createHash4 } from "crypto";
|
|
8561
9265
|
var POSTGRES_MIGRATION_LEDGER_TABLE = "open_loops_schema_migrations";
|
|
8562
9266
|
function checksumStorageSql(sql) {
|
|
8563
9267
|
const normalized = sql.trim().replace(/\r\n/g, `
|
|
8564
9268
|
`);
|
|
8565
|
-
return `sha256:${
|
|
9269
|
+
return `sha256:${createHash4("sha256").update(normalized).digest("hex")}`;
|
|
8566
9270
|
}
|
|
8567
9271
|
function migration(id, sql) {
|
|
8568
9272
|
return Object.freeze({ id, sql: sql.trim(), checksum: checksumStorageSql(sql) });
|
|
@@ -9058,7 +9762,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
9058
9762
|
{ name: "taskTitle", description: "Human-readable task title." },
|
|
9059
9763
|
projectPathVariable(),
|
|
9060
9764
|
{ name: "todosProjectPath", description: "Todos storage project path used in worker/verifier commands." },
|
|
9061
|
-
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
9062
9765
|
...routeScopeVariables(),
|
|
9063
9766
|
...workerVerifierAgentVariables({ addDirs: true, branchNoun: "task/event" })
|
|
9064
9767
|
]
|
|
@@ -9099,8 +9802,6 @@ var BUILTIN_TEMPLATE_SUMMARIES = [
|
|
|
9099
9802
|
variables: [
|
|
9100
9803
|
{ name: "taskId", required: true, description: "Todos task id." },
|
|
9101
9804
|
projectPathVariable(),
|
|
9102
|
-
{ name: "todosProjectPath", description: "Todos storage project path used in lifecycle commands." },
|
|
9103
|
-
{ name: "todosDbPath", description: "Optional exact Todos DB path used via TODOS_DB_PATH/HASNA_TODOS_DB_PATH." },
|
|
9104
9805
|
{ name: "authProfilePool", description: "Comma-separated Codewith profiles for worker/verifier rotation." },
|
|
9105
9806
|
roleAuthProfileVariable("triage"),
|
|
9106
9807
|
roleAuthProfileVariable("planner"),
|
|
@@ -9231,29 +9932,28 @@ function verifierRuntimeGuidance(input) {
|
|
|
9231
9932
|
].join(`
|
|
9232
9933
|
`);
|
|
9233
9934
|
}
|
|
9234
|
-
function todosInspectLine(todosProjectPath, taskId
|
|
9235
|
-
return `- Inspect first: ${
|
|
9935
|
+
function todosInspectLine(todosProjectPath, taskId) {
|
|
9936
|
+
return `- Inspect first: todos --project ${todosProjectPath} inspect ${taskId}`;
|
|
9236
9937
|
}
|
|
9237
|
-
function todosStartLine(todosProjectPath, taskId
|
|
9238
|
-
return `- Claim/start if appropriate: ${
|
|
9938
|
+
function todosStartLine(todosProjectPath, taskId) {
|
|
9939
|
+
return `- Claim/start if appropriate: todos --project ${todosProjectPath} start ${taskId}`;
|
|
9239
9940
|
}
|
|
9240
|
-
function todosEvidenceLine(todosProjectPath, taskId, placeholder
|
|
9241
|
-
return `- Record evidence: ${
|
|
9941
|
+
function todosEvidenceLine(todosProjectPath, taskId, placeholder) {
|
|
9942
|
+
return `- Record evidence: todos --project ${todosProjectPath} comment ${taskId} "<${placeholder}>"`;
|
|
9242
9943
|
}
|
|
9243
|
-
function todosVerificationLine(todosProjectPath, taskId
|
|
9244
|
-
return `- Record verification: ${
|
|
9944
|
+
function todosVerificationLine(todosProjectPath, taskId) {
|
|
9945
|
+
return `- Record verification: todos --project ${todosProjectPath} comment ${taskId} "<verification evidence or blocker>"`;
|
|
9245
9946
|
}
|
|
9246
|
-
function todosDoneLine(todosProjectPath, taskId
|
|
9247
|
-
return `- If valid and complete: ${
|
|
9947
|
+
function todosDoneLine(todosProjectPath, taskId) {
|
|
9948
|
+
return `- If valid and complete: todos --project ${todosProjectPath} done ${taskId}`;
|
|
9248
9949
|
}
|
|
9249
|
-
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines
|
|
9950
|
+
function todosExactCommandsFragment(todosProjectPath, taskId, commandLines) {
|
|
9250
9951
|
return [
|
|
9251
9952
|
`Todos project path: ${todosProjectPath}`,
|
|
9252
|
-
todosDbPath ? `Todos DB path: ${todosDbPath}` : undefined,
|
|
9253
9953
|
"Use these exact todos commands so worktree cwd inference cannot attach to the wrong project:",
|
|
9254
|
-
todosInspectLine(todosProjectPath, taskId
|
|
9954
|
+
todosInspectLine(todosProjectPath, taskId),
|
|
9255
9955
|
...commandLines
|
|
9256
|
-
]
|
|
9956
|
+
];
|
|
9257
9957
|
}
|
|
9258
9958
|
function worktreePrompt(plan) {
|
|
9259
9959
|
if (plan.enabled) {
|
|
@@ -9290,19 +9990,10 @@ function worktreeContextFragment(plan) {
|
|
|
9290
9990
|
function shellQuote2(value) {
|
|
9291
9991
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
9292
9992
|
}
|
|
9293
|
-
function
|
|
9294
|
-
return todosDbPath ? `TODOS_DB_PATH=${shellQuote2(todosDbPath)} HASNA_TODOS_DB_PATH=${shellQuote2(todosDbPath)} ` : "";
|
|
9295
|
-
}
|
|
9296
|
-
function todosCommand(todosProjectPath, todosDbPath) {
|
|
9297
|
-
return `${todosEnvPrefix(todosDbPath)}todos --project ${todosProjectPath}`;
|
|
9298
|
-
}
|
|
9299
|
-
function shellTodosCommand(todosProjectPath, todosDbPath) {
|
|
9300
|
-
return `${todosEnvPrefix(todosDbPath)}todos --project ${shellQuote2(todosProjectPath)}`;
|
|
9301
|
-
}
|
|
9302
|
-
function sourceTaskGateCommand(todosProjectPath, taskId, todosDbPath) {
|
|
9993
|
+
function sourceTaskGateCommand(todosProjectPath, taskId) {
|
|
9303
9994
|
return [
|
|
9304
9995
|
"set -euo pipefail",
|
|
9305
|
-
|
|
9996
|
+
`todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)} >/dev/null`,
|
|
9306
9997
|
`printf "source task %s resolved in todos project %s\\n" ${shellQuote2(taskId)} ${shellQuote2(todosProjectPath)}`
|
|
9307
9998
|
].join(`
|
|
9308
9999
|
`);
|
|
@@ -9362,10 +10053,10 @@ var LIFECYCLE_GATE_SCRIPT_TAIL = [
|
|
|
9362
10053
|
"console.log(`task lifecycle ${stage} gate passed for ${task.id || task.taskId || 'task'} status=${status || 'unknown'}`);"
|
|
9363
10054
|
].join(`
|
|
9364
10055
|
`);
|
|
9365
|
-
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker
|
|
10056
|
+
function lifecycleGateCommand(todosProjectPath, taskId, stage, goMarker, blockedMarker) {
|
|
9366
10057
|
return [
|
|
9367
10058
|
"set -euo pipefail",
|
|
9368
|
-
`task_json="$(${
|
|
10059
|
+
`task_json="$(todos --project ${shellQuote2(todosProjectPath)} --json inspect ${shellQuote2(taskId)})"`,
|
|
9369
10060
|
`TASK_JSON="$task_json" STAGE=${shellQuote2(stage)} bun - <<'BUN'`,
|
|
9370
10061
|
LIFECYCLE_GATE_SCRIPT_HEAD,
|
|
9371
10062
|
`const goMarker = ${JSON.stringify(goMarker)};`,
|
|
@@ -9381,7 +10072,6 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
9381
10072
|
"const artifactPath = process.env.OPENLOOPS_PR_HANDOFF_ARTIFACT || '';",
|
|
9382
10073
|
"const taskId = process.env.OPENLOOPS_PR_HANDOFF_TASK_ID || '';",
|
|
9383
10074
|
"const todosProject = process.env.OPENLOOPS_PR_HANDOFF_TODOS_PROJECT || '';",
|
|
9384
|
-
"const todosDbPath = process.env.OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH || '';",
|
|
9385
10075
|
"const fallbackWorktree = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE || process.cwd();",
|
|
9386
10076
|
"const expectedRoot = process.env.OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT || fallbackWorktree;",
|
|
9387
10077
|
"const expectedBranch = process.env.OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH || '';",
|
|
@@ -9399,8 +10089,7 @@ var PR_HANDOFF_SCRIPT = [
|
|
|
9399
10089
|
"};",
|
|
9400
10090
|
"const run = (command, args, options = {}) => spawnSync(command, args, { encoding: 'utf8', ...options });",
|
|
9401
10091
|
"const todosArgs = (...args) => todosProject ? ['--project', todosProject, ...args] : args;",
|
|
9402
|
-
"const
|
|
9403
|
-
"const todos = (...args) => run(todosBin, todosArgs(...args), { env: todosEnv });",
|
|
10092
|
+
"const todos = (...args) => run(todosBin, todosArgs(...args));",
|
|
9404
10093
|
"const comment = (text) => {",
|
|
9405
10094
|
" const result = todos('comment', taskId, text);",
|
|
9406
10095
|
" if (result.status !== 0) console.error(`failed to comment original task: ${result.stderr || result.stdout || result.status}`);",
|
|
@@ -9529,7 +10218,6 @@ function prHandoffCommand(opts) {
|
|
|
9529
10218
|
`export OPENLOOPS_PR_HANDOFF_ARTIFACT=${shellQuote2(opts.artifactPath)}`,
|
|
9530
10219
|
`export OPENLOOPS_PR_HANDOFF_TASK_ID=${shellQuote2(opts.taskId)}`,
|
|
9531
10220
|
`export OPENLOOPS_PR_HANDOFF_TODOS_PROJECT=${shellQuote2(opts.todosProjectPath)}`,
|
|
9532
|
-
opts.todosDbPath ? `export OPENLOOPS_PR_HANDOFF_TODOS_DB_PATH=${shellQuote2(opts.todosDbPath)}` : undefined,
|
|
9533
10221
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE=${shellQuote2(opts.worktreeCwd)}`,
|
|
9534
10222
|
`export OPENLOOPS_PR_HANDOFF_WORKTREE_ROOT=${shellQuote2(opts.worktreeRoot)}`,
|
|
9535
10223
|
`export OPENLOOPS_PR_HANDOFF_EXPECTED_BRANCH=${shellQuote2(opts.expectedBranch)}`,
|
|
@@ -9540,7 +10228,7 @@ function prHandoffCommand(opts) {
|
|
|
9540
10228
|
"bun - <<'BUN'",
|
|
9541
10229
|
PR_HANDOFF_SCRIPT,
|
|
9542
10230
|
"BUN"
|
|
9543
|
-
].
|
|
10231
|
+
].join(`
|
|
9544
10232
|
`);
|
|
9545
10233
|
}
|
|
9546
10234
|
// src/lib/templates-custom.ts
|
|
@@ -10130,12 +10818,12 @@ function commandStep(opts) {
|
|
|
10130
10818
|
...opts.blockedExitCodes ? { blockedExitCodes: opts.blockedExitCodes } : {}
|
|
10131
10819
|
};
|
|
10132
10820
|
}
|
|
10133
|
-
function sourceTaskGateStep(todosProjectPath, taskId, plan, description
|
|
10821
|
+
function sourceTaskGateStep(todosProjectPath, taskId, plan, description) {
|
|
10134
10822
|
return commandStep({
|
|
10135
10823
|
id: "source-task-gate",
|
|
10136
10824
|
name: "Source Task Gate",
|
|
10137
10825
|
description,
|
|
10138
|
-
command: sourceTaskGateCommand(todosProjectPath, taskId
|
|
10826
|
+
command: sourceTaskGateCommand(todosProjectPath, taskId),
|
|
10139
10827
|
cwd: plan.originalCwd,
|
|
10140
10828
|
timeoutMs: 60000,
|
|
10141
10829
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -10147,7 +10835,7 @@ function lifecycleGateStep(opts) {
|
|
|
10147
10835
|
name: opts.stage === "triage" ? "Triage Gate" : "Planner Gate",
|
|
10148
10836
|
description: opts.description,
|
|
10149
10837
|
dependsOn: opts.dependsOn,
|
|
10150
|
-
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker
|
|
10838
|
+
command: lifecycleGateCommand(opts.todosProjectPath, opts.taskId, opts.stage, opts.goMarker, opts.blockedMarker),
|
|
10151
10839
|
cwd: opts.plan.originalCwd,
|
|
10152
10840
|
timeoutMs: 2 * 60000,
|
|
10153
10841
|
blockedExitCodes: GATE_BLOCKED_EXIT_CODES
|
|
@@ -10156,7 +10844,7 @@ function lifecycleGateStep(opts) {
|
|
|
10156
10844
|
function prHandoffArtifactPath(plan, taskId) {
|
|
10157
10845
|
return join7(plan.cwd, ".openloops", "pr-handoff", `${slugSegment(taskId, "task")}.json`);
|
|
10158
10846
|
}
|
|
10159
|
-
function prHandoffStep(input, plan, todosProjectPath
|
|
10847
|
+
function prHandoffStep(input, plan, todosProjectPath) {
|
|
10160
10848
|
return commandStep({
|
|
10161
10849
|
id: "pr-handoff",
|
|
10162
10850
|
name: "PR Handoff",
|
|
@@ -10166,7 +10854,6 @@ function prHandoffStep(input, plan, todosProjectPath, todosDbPath) {
|
|
|
10166
10854
|
artifactPath: prHandoffArtifactPath(plan, input.taskId),
|
|
10167
10855
|
taskId: input.taskId,
|
|
10168
10856
|
todosProjectPath,
|
|
10169
|
-
todosDbPath,
|
|
10170
10857
|
worktreeCwd: plan.cwd,
|
|
10171
10858
|
worktreeRoot: plan.path ?? plan.cwd,
|
|
10172
10859
|
expectedBranch: plan.branch ?? ""
|
|
@@ -10253,7 +10940,6 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
10253
10940
|
if (!input.projectPath?.trim())
|
|
10254
10941
|
throw new Error("projectPath is required");
|
|
10255
10942
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
10256
|
-
const todosDbPath = input.todosDbPath;
|
|
10257
10943
|
const plan = worktreePlan(input, input.taskId);
|
|
10258
10944
|
const taskContext = {
|
|
10259
10945
|
taskId: input.taskId,
|
|
@@ -10264,17 +10950,15 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
10264
10950
|
projectPath: input.projectPath,
|
|
10265
10951
|
routeProjectPath: input.routeProjectPath,
|
|
10266
10952
|
projectGroup: input.projectGroup,
|
|
10267
|
-
todosProjectPath: input.todosProjectPath ?? (input.todosDbPath ? todosProjectPath : undefined),
|
|
10268
|
-
todosDbPath,
|
|
10269
10953
|
worktree: worktreeContextFragment(plan)
|
|
10270
10954
|
};
|
|
10271
10955
|
const workerPrompt = [
|
|
10272
10956
|
...goalHeaderFragment(`Complete todos task ${input.taskId} in ${input.projectPath}.`, "worker", "task"),
|
|
10273
10957
|
worktreePrompt(plan),
|
|
10274
10958
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
10275
|
-
todosStartLine(todosProjectPath, input.taskId
|
|
10276
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers"
|
|
10277
|
-
]
|
|
10959
|
+
todosStartLine(todosProjectPath, input.taskId),
|
|
10960
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence and blockers")
|
|
10961
|
+
]),
|
|
10278
10962
|
"Investigate first before changing files. Use the todos CLI as the source of truth for the task.",
|
|
10279
10963
|
"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.",
|
|
10280
10964
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
@@ -10287,9 +10971,9 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
10287
10971
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the worker step.`, "verifier", "task"),
|
|
10288
10972
|
worktreePrompt(plan),
|
|
10289
10973
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
10290
|
-
todosVerificationLine(todosProjectPath, input.taskId
|
|
10291
|
-
todosDoneLine(todosProjectPath, input.taskId
|
|
10292
|
-
]
|
|
10974
|
+
todosVerificationLine(todosProjectPath, input.taskId),
|
|
10975
|
+
todosDoneLine(todosProjectPath, input.taskId)
|
|
10976
|
+
]),
|
|
10293
10977
|
adversarialReviewFragment("the task, repository state, commits, tests, and worker evidence", TASK_REVIEW_FOCUS),
|
|
10294
10978
|
verifierRuntimeGuidance(input),
|
|
10295
10979
|
TASK_VERIFIER_DECISION_FRAGMENT,
|
|
@@ -10304,7 +10988,7 @@ function renderTodosTaskWorkerVerifierWorkflow(input) {
|
|
|
10304
10988
|
description: `Task-triggered worker/verifier workflow for ${taskLabel(input)}`,
|
|
10305
10989
|
version: 1,
|
|
10306
10990
|
steps: [
|
|
10307
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."
|
|
10991
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before worker execution when the source todos task is not resolvable."),
|
|
10308
10992
|
...workerVerifierSteps({
|
|
10309
10993
|
input,
|
|
10310
10994
|
seed: input.taskId,
|
|
@@ -10324,7 +11008,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10324
11008
|
if (!input.projectPath?.trim())
|
|
10325
11009
|
throw new Error("projectPath is required");
|
|
10326
11010
|
const todosProjectPath = input.todosProjectPath ?? input.routeProjectPath ?? input.projectPath;
|
|
10327
|
-
const todosDbPath = input.todosDbPath;
|
|
10328
11011
|
const plan = worktreePlan(input, input.taskId);
|
|
10329
11012
|
const taskContext = {
|
|
10330
11013
|
taskId: input.taskId,
|
|
@@ -10336,7 +11019,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10336
11019
|
routeProjectPath: input.routeProjectPath,
|
|
10337
11020
|
projectGroup: input.projectGroup,
|
|
10338
11021
|
todosProjectPath,
|
|
10339
|
-
todosDbPath,
|
|
10340
11022
|
worktree: worktreeContextFragment(plan)
|
|
10341
11023
|
};
|
|
10342
11024
|
const handoffArtifactPath = prHandoffArtifactPath(plan, input.taskId);
|
|
@@ -10350,8 +11032,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10350
11032
|
const shared = [
|
|
10351
11033
|
worktreePrompt(plan),
|
|
10352
11034
|
...todosExactCommandsFragment(todosProjectPath, input.taskId, [
|
|
10353
|
-
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker"
|
|
10354
|
-
]
|
|
11035
|
+
todosEvidenceLine(todosProjectPath, input.taskId, "concise evidence, decision, or blocker")
|
|
11036
|
+
]),
|
|
10355
11037
|
NO_TMUX_DISPATCH_FRAGMENT,
|
|
10356
11038
|
"Preserve unrelated user changes and keep scope tied to the task acceptance criteria.",
|
|
10357
11039
|
"",
|
|
@@ -10360,7 +11042,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10360
11042
|
].join(`
|
|
10361
11043
|
`);
|
|
10362
11044
|
const gateMarker = (stage, state) => `openloops:${stage}=${state} task=${input.taskId}${input.eventId ? ` event=${input.eventId}` : ""}`;
|
|
10363
|
-
const blockTaskCommand =
|
|
11045
|
+
const blockTaskCommand = `todos --project ${todosProjectPath} update ${input.taskId} --status blocked`;
|
|
10364
11046
|
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.`;
|
|
10365
11047
|
const triagePrompt = [
|
|
10366
11048
|
...goalHeaderFragment(`Triage todos task ${input.taskId} for safe automated execution.`, "triage", "lifecycle"),
|
|
@@ -10388,7 +11070,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10388
11070
|
const workerPrompt = [
|
|
10389
11071
|
...goalHeaderFragment(`Complete todos task ${input.taskId} according to the planner evidence.`, "worker", "lifecycle"),
|
|
10390
11072
|
shared,
|
|
10391
|
-
todosStartLine(todosProjectPath, input.taskId
|
|
11073
|
+
todosStartLine(todosProjectPath, input.taskId),
|
|
10392
11074
|
"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.",
|
|
10393
11075
|
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,
|
|
10394
11076
|
WORKER_LEAVES_COMPLETION_FRAGMENT
|
|
@@ -10397,8 +11079,8 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10397
11079
|
const verifierPrompt = [
|
|
10398
11080
|
...goalHeaderFragment(`Verify todos task ${input.taskId} after the full lifecycle worker step.`, "verifier", "lifecycle"),
|
|
10399
11081
|
shared,
|
|
10400
|
-
todosVerificationLine(todosProjectPath, input.taskId
|
|
10401
|
-
todosDoneLine(todosProjectPath, input.taskId
|
|
11082
|
+
todosVerificationLine(todosProjectPath, input.taskId),
|
|
11083
|
+
todosDoneLine(todosProjectPath, input.taskId),
|
|
10402
11084
|
adversarialReviewFragment("triage, plan, worker evidence, repo state, commits, tests, and acceptance criteria", TASK_REVIEW_FOCUS),
|
|
10403
11085
|
verifierRuntimeGuidance(input),
|
|
10404
11086
|
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,
|
|
@@ -10407,7 +11089,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10407
11089
|
].filter(Boolean).join(`
|
|
10408
11090
|
`);
|
|
10409
11091
|
const steps = [
|
|
10410
|
-
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."
|
|
11092
|
+
sourceTaskGateStep(todosProjectPath, input.taskId, plan, "Fail before lifecycle agents execute when the source todos task is not resolvable."),
|
|
10411
11093
|
{
|
|
10412
11094
|
id: "triage",
|
|
10413
11095
|
name: "Triage",
|
|
@@ -10421,7 +11103,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10421
11103
|
description: "Stop the lifecycle before planning when triage blocked or disallowed automation.",
|
|
10422
11104
|
dependsOn: ["triage"],
|
|
10423
11105
|
todosProjectPath,
|
|
10424
|
-
todosDbPath,
|
|
10425
11106
|
taskId: input.taskId,
|
|
10426
11107
|
goMarker: gateMarker("triage", "go"),
|
|
10427
11108
|
blockedMarker: gateMarker("triage", "blocked"),
|
|
@@ -10440,7 +11121,6 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10440
11121
|
description: "Stop the lifecycle before implementation when planning blocked or disallowed automation.",
|
|
10441
11122
|
dependsOn: ["planner"],
|
|
10442
11123
|
todosProjectPath,
|
|
10443
|
-
todosDbPath,
|
|
10444
11124
|
taskId: input.taskId,
|
|
10445
11125
|
goMarker: gateMarker("planner", "go"),
|
|
10446
11126
|
blockedMarker: gateMarker("planner", "blocked"),
|
|
@@ -10456,7 +11136,7 @@ function renderTaskLifecycleWorkflow(input) {
|
|
|
10456
11136
|
}
|
|
10457
11137
|
];
|
|
10458
11138
|
if (input.prHandoff) {
|
|
10459
|
-
steps.push(prHandoffStep(input, plan, todosProjectPath
|
|
11139
|
+
steps.push(prHandoffStep(input, plan, todosProjectPath));
|
|
10460
11140
|
}
|
|
10461
11141
|
steps.push({
|
|
10462
11142
|
id: "verifier",
|
|
@@ -10681,7 +11361,6 @@ function renderLifecycleBoundedTemplate(id, values) {
|
|
|
10681
11361
|
taskTitle: values.taskTitle,
|
|
10682
11362
|
taskDescription: values.taskDescription,
|
|
10683
11363
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
10684
|
-
todosDbPath: values.todosDbPath,
|
|
10685
11364
|
triageAuthProfile: values.triageAuthProfile,
|
|
10686
11365
|
plannerAuthProfile: values.plannerAuthProfile,
|
|
10687
11366
|
triageAccount: accountVar(values.triageAccount, values.accountTool),
|
|
@@ -10746,7 +11425,6 @@ function renderBuiltinLoopTemplate(id, values) {
|
|
|
10746
11425
|
taskTitle: values.taskTitle,
|
|
10747
11426
|
taskDescription: values.taskDescription,
|
|
10748
11427
|
todosProjectPath: values.todosProjectPath ?? values.todosProject,
|
|
10749
|
-
todosDbPath: values.todosDbPath,
|
|
10750
11428
|
eventId: values.eventId,
|
|
10751
11429
|
eventType: values.eventType
|
|
10752
11430
|
});
|
|
@@ -11041,7 +11719,9 @@ function buildScriptInventoryReport(store, opts = {}) {
|
|
|
11041
11719
|
export {
|
|
11042
11720
|
workflowExecutionOrder,
|
|
11043
11721
|
workflowBodyFromJson,
|
|
11722
|
+
validateLoopsMigrationBundle,
|
|
11044
11723
|
tick,
|
|
11724
|
+
selfHostedControlPlaneSummary,
|
|
11045
11725
|
runLoopNow,
|
|
11046
11726
|
runGoal,
|
|
11047
11727
|
runDoctor,
|
|
@@ -11053,8 +11733,10 @@ export {
|
|
|
11053
11733
|
renderLoopTemplate,
|
|
11054
11734
|
renderEventWorkerVerifierWorkflow,
|
|
11055
11735
|
renderBoundedAgentWorkerVerifierWorkflow,
|
|
11736
|
+
registerSelfHostedRunner,
|
|
11056
11737
|
refreshLoopMachine,
|
|
11057
11738
|
readyNodeKeys,
|
|
11739
|
+
publicMigrationBundle,
|
|
11058
11740
|
preflightWorkflow,
|
|
11059
11741
|
preflightTarget,
|
|
11060
11742
|
parseDuration,
|
|
@@ -11062,6 +11744,7 @@ export {
|
|
|
11062
11744
|
openAutomationsRuntimeBinding,
|
|
11063
11745
|
normalizeLoopDeploymentMode,
|
|
11064
11746
|
nextCronRun,
|
|
11747
|
+
migrationHash,
|
|
11065
11748
|
loops,
|
|
11066
11749
|
loopControlPlaneConfig,
|
|
11067
11750
|
listToolsForCli,
|
|
@@ -11070,6 +11753,7 @@ export {
|
|
|
11070
11753
|
isTerminal as isGoalTerminal,
|
|
11071
11754
|
initialNextRun,
|
|
11072
11755
|
getLoopTemplate,
|
|
11756
|
+
exportLoopsMigrationBundle,
|
|
11073
11757
|
expectationForLoop,
|
|
11074
11758
|
executeWorkflow,
|
|
11075
11759
|
executeTarget,
|
|
@@ -11082,11 +11766,14 @@ export {
|
|
|
11082
11766
|
computeNextAfter,
|
|
11083
11767
|
classifyRunFailure,
|
|
11084
11768
|
checksumStorageSql,
|
|
11769
|
+
buildSelfHostedMigrationPlan,
|
|
11085
11770
|
buildScriptInventoryReport,
|
|
11086
11771
|
buildNameHygieneReport,
|
|
11772
|
+
buildImportMigrationPlan,
|
|
11087
11773
|
buildHealthReport,
|
|
11088
11774
|
buildDuplicateOverlapReport,
|
|
11089
11775
|
buildDeploymentStatus,
|
|
11776
|
+
applyImportMigrationBundle,
|
|
11090
11777
|
ValidationError,
|
|
11091
11778
|
TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID,
|
|
11092
11779
|
Store,
|
|
@@ -11098,6 +11785,7 @@ export {
|
|
|
11098
11785
|
LoopNotFoundError,
|
|
11099
11786
|
LoopArchivedError,
|
|
11100
11787
|
LOOP_DEPLOYMENT_MODES,
|
|
11788
|
+
LOOPS_MIGRATION_SCHEMA,
|
|
11101
11789
|
LOOPS_MCP_TOOLS,
|
|
11102
11790
|
EVENT_WORKER_VERIFIER_TEMPLATE_ID,
|
|
11103
11791
|
CodedError,
|