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