@hasna/loops 0.3.3 → 0.3.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/dist/cli/index.js +365 -35
- package/dist/daemon/index.js +316 -23
- package/dist/index.d.ts +1 -0
- package/dist/index.js +339 -26
- package/dist/lib/executor.d.ts +10 -1
- package/dist/lib/machines.d.ts +16 -0
- package/dist/lib/store.js +10 -2
- package/dist/sdk/index.js +313 -20
- package/dist/types.d.ts +15 -0
- package/package.json +2 -1
package/dist/cli/index.js
CHANGED
|
@@ -347,6 +347,7 @@ function rowToLoop(row) {
|
|
|
347
347
|
status: row.status,
|
|
348
348
|
schedule: JSON.parse(row.schedule_json),
|
|
349
349
|
target: JSON.parse(row.target_json),
|
|
350
|
+
machine: row.machine_json ? JSON.parse(row.machine_json) : undefined,
|
|
350
351
|
nextRunAt: row.next_run_at ?? undefined,
|
|
351
352
|
retryScheduledFor: row.retry_scheduled_for ?? undefined,
|
|
352
353
|
catchUp: row.catch_up,
|
|
@@ -489,6 +490,7 @@ class Store {
|
|
|
489
490
|
status TEXT NOT NULL,
|
|
490
491
|
schedule_json TEXT NOT NULL,
|
|
491
492
|
target_json TEXT NOT NULL,
|
|
493
|
+
machine_json TEXT,
|
|
492
494
|
next_run_at TEXT,
|
|
493
495
|
retry_scheduled_for TEXT,
|
|
494
496
|
catch_up TEXT NOT NULL,
|
|
@@ -610,10 +612,14 @@ class Store {
|
|
|
610
612
|
);
|
|
611
613
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
612
614
|
`);
|
|
615
|
+
try {
|
|
616
|
+
this.db.query("ALTER TABLE loops ADD COLUMN machine_json TEXT").run();
|
|
617
|
+
} catch {}
|
|
613
618
|
try {
|
|
614
619
|
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
615
620
|
} catch {}
|
|
616
621
|
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
622
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0002_loop_machines", nowIso());
|
|
617
623
|
}
|
|
618
624
|
assertDaemonLeaseFence(opts = {}, now = nowIso()) {
|
|
619
625
|
if (!opts.daemonLeaseId)
|
|
@@ -631,6 +637,7 @@ class Store {
|
|
|
631
637
|
status: "active",
|
|
632
638
|
schedule: input.schedule,
|
|
633
639
|
target: input.target,
|
|
640
|
+
machine: input.machine,
|
|
634
641
|
nextRunAt: initialNextRun(input.schedule, from),
|
|
635
642
|
catchUp: input.catchUp ?? "latest",
|
|
636
643
|
catchUpLimit: input.catchUpLimit ?? 50,
|
|
@@ -642,9 +649,9 @@ class Store {
|
|
|
642
649
|
createdAt: now,
|
|
643
650
|
updatedAt: now
|
|
644
651
|
};
|
|
645
|
-
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, next_run_at, retry_scheduled_for,
|
|
652
|
+
this.db.query(`INSERT INTO loops (id, name, description, status, schedule_json, target_json, machine_json, next_run_at, retry_scheduled_for,
|
|
646
653
|
catch_up, catch_up_limit, overlap, max_attempts, retry_delay_ms, lease_ms, expires_at, created_at, updated_at)
|
|
647
|
-
VALUES ($id, $name, $description, $status, $schedule, $target, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
654
|
+
VALUES ($id, $name, $description, $status, $schedule, $target, $machine, $nextRun, NULL, $catchUp, $catchUpLimit,
|
|
648
655
|
$overlap, $maxAttempts, $retryDelay, $leaseMs, $expiresAt, $created, $updated)`).run({
|
|
649
656
|
$id: loop.id,
|
|
650
657
|
$name: loop.name,
|
|
@@ -652,6 +659,7 @@ class Store {
|
|
|
652
659
|
$status: loop.status,
|
|
653
660
|
$schedule: JSON.stringify(loop.schedule),
|
|
654
661
|
$target: JSON.stringify(loop.target),
|
|
662
|
+
$machine: loop.machine ? JSON.stringify(loop.machine) : null,
|
|
655
663
|
$nextRun: loop.nextRunAt ?? null,
|
|
656
664
|
$catchUp: loop.catchUp,
|
|
657
665
|
$catchUpLimit: loop.catchUpLimit,
|
|
@@ -1602,8 +1610,9 @@ function publicWorkflowEvent(event) {
|
|
|
1602
1610
|
}
|
|
1603
1611
|
|
|
1604
1612
|
// src/lib/executor.ts
|
|
1605
|
-
import { spawn } from "child_process";
|
|
1613
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
1606
1614
|
import { once } from "events";
|
|
1615
|
+
import { resolveMachineCommand } from "@hasna/machines/consumer";
|
|
1607
1616
|
|
|
1608
1617
|
// src/lib/accounts.ts
|
|
1609
1618
|
import { spawnSync } from "child_process";
|
|
@@ -1760,6 +1769,59 @@ function commandNotFoundMessage(command, env = process.env) {
|
|
|
1760
1769
|
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
1761
1770
|
}
|
|
1762
1771
|
|
|
1772
|
+
// src/lib/machines.ts
|
|
1773
|
+
import {
|
|
1774
|
+
discoverMachineTopology,
|
|
1775
|
+
resolveMachineRoute
|
|
1776
|
+
} from "@hasna/machines/consumer";
|
|
1777
|
+
function compact(value) {
|
|
1778
|
+
const text = value?.trim();
|
|
1779
|
+
return text ? text : undefined;
|
|
1780
|
+
}
|
|
1781
|
+
function entryToSummary(entry, topology) {
|
|
1782
|
+
return {
|
|
1783
|
+
id: entry.machine_id,
|
|
1784
|
+
hostname: compact(entry.hostname),
|
|
1785
|
+
platform: compact(entry.platform),
|
|
1786
|
+
user: compact(entry.user),
|
|
1787
|
+
workspacePath: compact(entry.workspace_path),
|
|
1788
|
+
route: entry.ssh.route,
|
|
1789
|
+
local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
|
|
1790
|
+
heartbeatStatus: entry.heartbeat_status,
|
|
1791
|
+
tailscaleOnline: entry.tailscale.online,
|
|
1792
|
+
tags: entry.tags
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
function machineFromRoute(route, topology) {
|
|
1796
|
+
if (!route.ok || !route.machine_id) {
|
|
1797
|
+
throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
|
|
1798
|
+
}
|
|
1799
|
+
const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
|
|
1800
|
+
return {
|
|
1801
|
+
id: route.machine_id,
|
|
1802
|
+
requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
|
|
1803
|
+
route: route.route,
|
|
1804
|
+
local: route.local,
|
|
1805
|
+
confidence: route.confidence,
|
|
1806
|
+
workspacePath: compact(entry?.workspace_path),
|
|
1807
|
+
resolvedAt: route.generated_at,
|
|
1808
|
+
packageVersion: route.package.version,
|
|
1809
|
+
warnings: route.warnings.length ? route.warnings : undefined
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
function listOpenMachines() {
|
|
1813
|
+
const topology = discoverMachineTopology();
|
|
1814
|
+
return topology.machines.map((entry) => entryToSummary(entry, topology));
|
|
1815
|
+
}
|
|
1816
|
+
function resolveLoopMachine(machineId) {
|
|
1817
|
+
const topology = discoverMachineTopology();
|
|
1818
|
+
const route = resolveMachineRoute(machineId, { topology });
|
|
1819
|
+
return machineFromRoute(route, topology);
|
|
1820
|
+
}
|
|
1821
|
+
function refreshLoopMachine(machine) {
|
|
1822
|
+
return resolveLoopMachine(machine.id);
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1763
1825
|
// src/lib/executor.ts
|
|
1764
1826
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
1765
1827
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
@@ -1780,6 +1842,23 @@ var AUTH_ENV_KEYS = [
|
|
|
1780
1842
|
"XDG_STATE_HOME",
|
|
1781
1843
|
"XDG_CACHE_HOME"
|
|
1782
1844
|
];
|
|
1845
|
+
var TRANSPORT_ENV_KEYS = new Set([
|
|
1846
|
+
"BUN_INSTALL",
|
|
1847
|
+
"HOME",
|
|
1848
|
+
"LANG",
|
|
1849
|
+
"LANGUAGE",
|
|
1850
|
+
"LOGNAME",
|
|
1851
|
+
"PATH",
|
|
1852
|
+
"SHELL",
|
|
1853
|
+
"SSH_AGENT_PID",
|
|
1854
|
+
"SSH_AUTH_SOCK",
|
|
1855
|
+
"TERM",
|
|
1856
|
+
"TMP",
|
|
1857
|
+
"TMPDIR",
|
|
1858
|
+
"TEMP",
|
|
1859
|
+
"USER",
|
|
1860
|
+
"XDG_RUNTIME_DIR"
|
|
1861
|
+
]);
|
|
1783
1862
|
function appendBounded(current, chunk, maxBytes) {
|
|
1784
1863
|
const next = current + chunk.toString("utf8");
|
|
1785
1864
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -1806,6 +1885,29 @@ function killProcessGroup(pid) {
|
|
|
1806
1885
|
}
|
|
1807
1886
|
}, 2000).unref();
|
|
1808
1887
|
}
|
|
1888
|
+
function shellQuote(value) {
|
|
1889
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1890
|
+
}
|
|
1891
|
+
function metadataEnv(metadata) {
|
|
1892
|
+
const env = {};
|
|
1893
|
+
if (metadata.loopId)
|
|
1894
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1895
|
+
if (metadata.loopName)
|
|
1896
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1897
|
+
if (metadata.runId)
|
|
1898
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1899
|
+
if (metadata.scheduledFor)
|
|
1900
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1901
|
+
if (metadata.workflowId)
|
|
1902
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1903
|
+
if (metadata.workflowName)
|
|
1904
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1905
|
+
if (metadata.workflowRunId)
|
|
1906
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1907
|
+
if (metadata.workflowStepId)
|
|
1908
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1909
|
+
return env;
|
|
1910
|
+
}
|
|
1809
1911
|
function providerCommand(provider) {
|
|
1810
1912
|
switch (provider) {
|
|
1811
1913
|
case "claude":
|
|
@@ -1927,26 +2029,213 @@ function executionEnv(spec, metadata, opts) {
|
|
|
1927
2029
|
}
|
|
1928
2030
|
Object.assign(env, spec.env ?? {});
|
|
1929
2031
|
env.PATH = normalizeExecutionPath(env);
|
|
1930
|
-
|
|
1931
|
-
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1932
|
-
if (metadata.loopName)
|
|
1933
|
-
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1934
|
-
if (metadata.runId)
|
|
1935
|
-
env.LOOPS_RUN_ID = metadata.runId;
|
|
1936
|
-
if (metadata.scheduledFor)
|
|
1937
|
-
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1938
|
-
if (metadata.workflowId)
|
|
1939
|
-
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1940
|
-
if (metadata.workflowName)
|
|
1941
|
-
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1942
|
-
if (metadata.workflowRunId)
|
|
1943
|
-
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1944
|
-
if (metadata.workflowStepId)
|
|
1945
|
-
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
2032
|
+
Object.assign(env, metadataEnv(metadata));
|
|
1946
2033
|
return env;
|
|
1947
2034
|
}
|
|
2035
|
+
function resolvedMachine(opts) {
|
|
2036
|
+
if (!opts.machine)
|
|
2037
|
+
return;
|
|
2038
|
+
return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
|
|
2039
|
+
}
|
|
2040
|
+
function commandForShell(spec) {
|
|
2041
|
+
if (!spec.args.length)
|
|
2042
|
+
return spec.command;
|
|
2043
|
+
return [spec.command, ...spec.args.map(shellQuote)].join(" ");
|
|
2044
|
+
}
|
|
2045
|
+
function hereDoc(value) {
|
|
2046
|
+
let delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
2047
|
+
while (value.split(/\r?\n/).includes(delimiter2)) {
|
|
2048
|
+
delimiter2 = `__OPENLOOPS_STDIN_${Math.random().toString(36).slice(2).toUpperCase()}__`;
|
|
2049
|
+
}
|
|
2050
|
+
return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
|
|
2051
|
+
}
|
|
2052
|
+
function remoteBootstrapLines(spec, metadata) {
|
|
2053
|
+
const lines = [
|
|
2054
|
+
"set -e",
|
|
2055
|
+
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
|
|
2056
|
+
];
|
|
2057
|
+
if (spec.cwd)
|
|
2058
|
+
lines.push(`cd ${shellQuote(spec.cwd)}`);
|
|
2059
|
+
if (spec.account) {
|
|
2060
|
+
if (!spec.accountTool)
|
|
2061
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
2062
|
+
lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
|
|
2063
|
+
}
|
|
2064
|
+
for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
|
|
2065
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
2066
|
+
continue;
|
|
2067
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
2068
|
+
}
|
|
2069
|
+
return lines;
|
|
2070
|
+
}
|
|
2071
|
+
function remoteScript(spec, metadata) {
|
|
2072
|
+
const lines = remoteBootstrapLines(spec, metadata);
|
|
2073
|
+
let stdinRedirect = "";
|
|
2074
|
+
if (spec.stdin !== undefined) {
|
|
2075
|
+
lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
|
|
2076
|
+
lines.push(...hereDoc(spec.stdin));
|
|
2077
|
+
stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
|
|
2078
|
+
}
|
|
2079
|
+
const invocation = spec.shell ? `sh -lc ${shellQuote(commandForShell(spec))}${stdinRedirect}` : `${[spec.command, ...spec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
|
|
2080
|
+
lines.push(invocation);
|
|
2081
|
+
return `${lines.join(`
|
|
2082
|
+
`)}
|
|
2083
|
+
`;
|
|
2084
|
+
}
|
|
2085
|
+
function remotePreflightScript(spec, metadata) {
|
|
2086
|
+
return [
|
|
2087
|
+
...remoteBootstrapLines(spec, metadata),
|
|
2088
|
+
"command -v bash >/dev/null 2>&1",
|
|
2089
|
+
`command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
|
|
2090
|
+
].join(`
|
|
2091
|
+
`);
|
|
2092
|
+
}
|
|
2093
|
+
function transportEnv(opts) {
|
|
2094
|
+
const source = opts.env ?? process.env;
|
|
2095
|
+
const env = {};
|
|
2096
|
+
for (const [key, value] of Object.entries(source)) {
|
|
2097
|
+
if (value === undefined)
|
|
2098
|
+
continue;
|
|
2099
|
+
if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
|
|
2100
|
+
env[key] = value;
|
|
2101
|
+
}
|
|
2102
|
+
env.PATH = normalizeExecutionPath(env);
|
|
2103
|
+
return env;
|
|
2104
|
+
}
|
|
2105
|
+
function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
2106
|
+
const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2107
|
+
const result = spawnSync2(plan.command, plan.args, {
|
|
2108
|
+
encoding: "utf8",
|
|
2109
|
+
env: transportEnv(opts),
|
|
2110
|
+
input: remotePreflightScript(spec, metadata),
|
|
2111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2112
|
+
timeout: 15000
|
|
2113
|
+
});
|
|
2114
|
+
if (result.error)
|
|
2115
|
+
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
2116
|
+
if ((result.status ?? 1) !== 0) {
|
|
2117
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
2118
|
+
throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
async function executeRemoteSpec(spec, machine, metadata, opts) {
|
|
2122
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
2123
|
+
const startedAt = nowIso();
|
|
2124
|
+
let stdout = "";
|
|
2125
|
+
let stderr = "";
|
|
2126
|
+
let timedOut = false;
|
|
2127
|
+
let exitCode;
|
|
2128
|
+
let error;
|
|
2129
|
+
let plan;
|
|
2130
|
+
let script;
|
|
2131
|
+
try {
|
|
2132
|
+
plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
2133
|
+
script = remoteScript(spec, metadata);
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
return {
|
|
2136
|
+
status: "failed",
|
|
2137
|
+
stdout: "",
|
|
2138
|
+
stderr: "",
|
|
2139
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2140
|
+
startedAt,
|
|
2141
|
+
finishedAt: nowIso(),
|
|
2142
|
+
durationMs: 0
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
const child = spawn(plan.command, plan.args, {
|
|
2146
|
+
env: transportEnv(opts),
|
|
2147
|
+
detached: true,
|
|
2148
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2149
|
+
});
|
|
2150
|
+
if (child.pid)
|
|
2151
|
+
opts.onSpawn?.(child.pid);
|
|
2152
|
+
child.stdin?.on("error", (err) => {
|
|
2153
|
+
if (err.code !== "EPIPE")
|
|
2154
|
+
error = err.message;
|
|
2155
|
+
});
|
|
2156
|
+
child.stdin?.end(script);
|
|
2157
|
+
const abortHandler = () => {
|
|
2158
|
+
error = "cancelled";
|
|
2159
|
+
if (child.pid)
|
|
2160
|
+
killProcessGroup(child.pid);
|
|
2161
|
+
};
|
|
2162
|
+
if (opts.signal?.aborted)
|
|
2163
|
+
abortHandler();
|
|
2164
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
2165
|
+
child.stdout?.on("data", (chunk) => {
|
|
2166
|
+
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
2167
|
+
});
|
|
2168
|
+
child.stderr?.on("data", (chunk) => {
|
|
2169
|
+
stderr = appendBounded(stderr, chunk, maxOutputBytes);
|
|
2170
|
+
});
|
|
2171
|
+
const timer = setTimeout(() => {
|
|
2172
|
+
timedOut = true;
|
|
2173
|
+
if (child.pid)
|
|
2174
|
+
killProcessGroup(child.pid);
|
|
2175
|
+
}, spec.timeoutMs);
|
|
2176
|
+
timer.unref();
|
|
2177
|
+
try {
|
|
2178
|
+
const [code, signal] = await once(child, "exit");
|
|
2179
|
+
if (typeof code === "number")
|
|
2180
|
+
exitCode = code;
|
|
2181
|
+
if (signal)
|
|
2182
|
+
error = `terminated by ${signal}`;
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
error = err instanceof Error ? err.message : String(err);
|
|
2185
|
+
} finally {
|
|
2186
|
+
clearTimeout(timer);
|
|
2187
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
2188
|
+
}
|
|
2189
|
+
const finishedAt = nowIso();
|
|
2190
|
+
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
2191
|
+
if (timedOut) {
|
|
2192
|
+
return {
|
|
2193
|
+
status: "timed_out",
|
|
2194
|
+
exitCode,
|
|
2195
|
+
stdout,
|
|
2196
|
+
stderr,
|
|
2197
|
+
error: `timed out after ${spec.timeoutMs}ms`,
|
|
2198
|
+
pid: child.pid,
|
|
2199
|
+
startedAt,
|
|
2200
|
+
finishedAt,
|
|
2201
|
+
durationMs
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
if (error || exitCode !== 0) {
|
|
2205
|
+
return {
|
|
2206
|
+
status: "failed",
|
|
2207
|
+
exitCode,
|
|
2208
|
+
stdout,
|
|
2209
|
+
stderr,
|
|
2210
|
+
error: error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`,
|
|
2211
|
+
pid: child.pid,
|
|
2212
|
+
startedAt,
|
|
2213
|
+
finishedAt,
|
|
2214
|
+
durationMs
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
return {
|
|
2218
|
+
status: "succeeded",
|
|
2219
|
+
exitCode,
|
|
2220
|
+
stdout,
|
|
2221
|
+
stderr,
|
|
2222
|
+
pid: child.pid,
|
|
2223
|
+
startedAt,
|
|
2224
|
+
finishedAt,
|
|
2225
|
+
durationMs
|
|
2226
|
+
};
|
|
2227
|
+
}
|
|
1948
2228
|
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1949
2229
|
const spec = commandSpec(target);
|
|
2230
|
+
const machine = resolvedMachine(opts);
|
|
2231
|
+
if (machine && !machine.local) {
|
|
2232
|
+
preflightRemoteSpec(spec, machine, metadata, opts);
|
|
2233
|
+
return {
|
|
2234
|
+
command: spec.command,
|
|
2235
|
+
accountProfile: spec.account?.profile,
|
|
2236
|
+
accountTool: spec.accountTool
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
1950
2239
|
const env = executionEnv(spec, metadata, opts);
|
|
1951
2240
|
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1952
2241
|
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
@@ -1959,6 +2248,9 @@ function preflightTarget(target, metadata = {}, opts = {}) {
|
|
|
1959
2248
|
}
|
|
1960
2249
|
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1961
2250
|
const spec = commandSpec(target);
|
|
2251
|
+
const machine = resolvedMachine(opts);
|
|
2252
|
+
if (machine && !machine.local)
|
|
2253
|
+
return executeRemoteSpec(spec, machine, metadata, opts);
|
|
1962
2254
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1963
2255
|
const startedAt = nowIso();
|
|
1964
2256
|
let stdout = "";
|
|
@@ -2074,7 +2366,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
2074
2366
|
loopName: loop.name,
|
|
2075
2367
|
runId: run.id,
|
|
2076
2368
|
scheduledFor: run.scheduledFor
|
|
2077
|
-
}, opts);
|
|
2369
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
2078
2370
|
}
|
|
2079
2371
|
|
|
2080
2372
|
// src/lib/workflow-runner.ts
|
|
@@ -2176,6 +2468,7 @@ async function executeWorkflow(store, workflow, opts = {}) {
|
|
|
2176
2468
|
try {
|
|
2177
2469
|
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
2178
2470
|
...opts,
|
|
2471
|
+
machine: opts.machine ?? opts.loop?.machine,
|
|
2179
2472
|
signal: controller.signal,
|
|
2180
2473
|
onSpawn: (pid) => {
|
|
2181
2474
|
opts.beforePersist?.();
|
|
@@ -2778,7 +3071,7 @@ async function startDaemon(opts) {
|
|
|
2778
3071
|
|
|
2779
3072
|
// src/daemon/install.ts
|
|
2780
3073
|
import { chmodSync, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
2781
|
-
import { spawnSync as
|
|
3074
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
2782
3075
|
import { dirname as dirname3 } from "path";
|
|
2783
3076
|
function installStartup(cliEntry, execPath = process.execPath, args = ["daemon", "run"]) {
|
|
2784
3077
|
const command = [execPath, cliEntry, ...args].join(" ");
|
|
@@ -2848,7 +3141,7 @@ ${args.map((arg) => ` <string>${arg}</string>`).join(`
|
|
|
2848
3141
|
function enableStartup(result) {
|
|
2849
3142
|
const commands = result.platform === "linux" ? ["systemctl --user daemon-reload", "systemctl --user enable --now loops-daemon.service"] : result.platform === "darwin" ? [`launchctl load -w ${result.path}`] : [];
|
|
2850
3143
|
return commands.map((command) => {
|
|
2851
|
-
const run =
|
|
3144
|
+
const run = spawnSync3("sh", ["-c", command], {
|
|
2852
3145
|
encoding: "utf8",
|
|
2853
3146
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2854
3147
|
});
|
|
@@ -2862,7 +3155,7 @@ function enableStartup(result) {
|
|
|
2862
3155
|
}
|
|
2863
3156
|
|
|
2864
3157
|
// src/lib/doctor.ts
|
|
2865
|
-
import { spawnSync as
|
|
3158
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
2866
3159
|
import { accessSync as accessSync2, constants as constants2 } from "fs";
|
|
2867
3160
|
var PROVIDER_COMMANDS = [
|
|
2868
3161
|
"claude",
|
|
@@ -2873,11 +3166,11 @@ var PROVIDER_COMMANDS = [
|
|
|
2873
3166
|
"codex"
|
|
2874
3167
|
];
|
|
2875
3168
|
function hasCommand(command) {
|
|
2876
|
-
const result =
|
|
3169
|
+
const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
|
|
2877
3170
|
return (result.status ?? 1) === 0;
|
|
2878
3171
|
}
|
|
2879
3172
|
function commandVersion(command) {
|
|
2880
|
-
const result =
|
|
3173
|
+
const result = spawnSync4(command, ["--version"], {
|
|
2881
3174
|
encoding: "utf8",
|
|
2882
3175
|
stdio: ["ignore", "pipe", "pipe"]
|
|
2883
3176
|
});
|
|
@@ -2903,6 +3196,23 @@ function runDoctor(store) {
|
|
|
2903
3196
|
checks.push(bunVersion ? { id: "bun", status: "ok", message: "bun is available", detail: bunVersion } : { id: "bun", status: "fail", message: "bun is not available on PATH" });
|
|
2904
3197
|
const accountsVersion = commandVersion("accounts");
|
|
2905
3198
|
checks.push(accountsVersion ? { id: "accounts", status: "ok", message: "accounts is available", detail: accountsVersion } : { id: "accounts", status: "warn", message: "accounts CLI is not available; account-routed steps will fail" });
|
|
3199
|
+
try {
|
|
3200
|
+
const machines = listOpenMachines();
|
|
3201
|
+
const local = machines.find((machine) => machine.local);
|
|
3202
|
+
checks.push({
|
|
3203
|
+
id: "machines",
|
|
3204
|
+
status: "ok",
|
|
3205
|
+
message: `OpenMachines topology available (${machines.length} machine(s))`,
|
|
3206
|
+
detail: local ? `local=${local.id}` : undefined
|
|
3207
|
+
});
|
|
3208
|
+
} catch (error) {
|
|
3209
|
+
checks.push({
|
|
3210
|
+
id: "machines",
|
|
3211
|
+
status: "warn",
|
|
3212
|
+
message: "OpenMachines topology is not available; machine-assigned loops will fail",
|
|
3213
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
2906
3216
|
for (const command of PROVIDER_COMMANDS) {
|
|
2907
3217
|
checks.push(hasCommand(command) ? { id: `provider:${command}`, status: "ok", message: `${command} is available` } : { id: `provider:${command}`, status: "warn", message: `${command} is not on PATH` });
|
|
2908
3218
|
}
|
|
@@ -2915,16 +3225,16 @@ function runDoctor(store) {
|
|
|
2915
3225
|
if (loop.target.type === "workflow") {
|
|
2916
3226
|
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
2917
3227
|
for (const step of workflowExecutionOrder(workflow)) {
|
|
2918
|
-
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id });
|
|
3228
|
+
preflightTarget({ ...step.target, account: step.account ?? step.target.account, timeoutMs: step.timeoutMs ?? step.target.timeoutMs }, { loopId: loop.id, loopName: loop.name, workflowId: workflow.id, workflowName: workflow.name, workflowStepId: step.id }, { machine: loop.machine });
|
|
2919
3229
|
}
|
|
2920
3230
|
} else {
|
|
2921
|
-
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name });
|
|
3231
|
+
preflightTarget(loop.target, { loopId: loop.id, loopName: loop.name }, { machine: loop.machine });
|
|
2922
3232
|
}
|
|
2923
3233
|
checks.push({ id: `loop:${loop.id}:preflight`, status: "ok", message: `active loop target is ready: ${loop.name}` });
|
|
2924
3234
|
} catch (error) {
|
|
2925
3235
|
checks.push({
|
|
2926
3236
|
id: `loop:${loop.id}:preflight`,
|
|
2927
|
-
status: "
|
|
3237
|
+
status: "fail",
|
|
2928
3238
|
message: `active loop target preflight failed: ${loop.name}`,
|
|
2929
3239
|
detail: error instanceof Error ? error.message : String(error)
|
|
2930
3240
|
});
|
|
@@ -2938,7 +3248,7 @@ function runDoctor(store) {
|
|
|
2938
3248
|
|
|
2939
3249
|
// src/cli/index.ts
|
|
2940
3250
|
var program = new Command;
|
|
2941
|
-
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.
|
|
3251
|
+
program.name("loops").description("Persistent local loops for commands and headless coding agents").version("0.3.4");
|
|
2942
3252
|
program.option("-j, --json", "print JSON");
|
|
2943
3253
|
function isJson() {
|
|
2944
3254
|
return Boolean(program.opts().json);
|
|
@@ -3017,6 +3327,7 @@ function baseCreateInput(name, opts, target) {
|
|
|
3017
3327
|
description: typeof opts.description === "string" ? opts.description : undefined,
|
|
3018
3328
|
schedule,
|
|
3019
3329
|
target,
|
|
3330
|
+
machine: typeof opts.machine === "string" ? resolveLoopMachine(opts.machine) : undefined,
|
|
3020
3331
|
...policy,
|
|
3021
3332
|
expiresAt: typeof opts.expiresAt === "string" ? new Date(opts.expiresAt).toISOString() : undefined
|
|
3022
3333
|
};
|
|
@@ -3027,6 +3338,9 @@ function addScheduleOptions(command) {
|
|
|
3027
3338
|
function addAccountOptions(command) {
|
|
3028
3339
|
return command.option("--account <profile>", "OpenAccounts profile name for this target").option("--account-tool <tool>", "OpenAccounts tool id; defaults from provider for agents");
|
|
3029
3340
|
}
|
|
3341
|
+
function addMachineOptions(command) {
|
|
3342
|
+
return command.option("--machine <id>", "OpenMachines machine id to assign this loop to");
|
|
3343
|
+
}
|
|
3030
3344
|
function accountFromOpts(opts) {
|
|
3031
3345
|
if (!opts.account && opts.accountTool)
|
|
3032
3346
|
throw new Error("--account-tool requires --account");
|
|
@@ -3040,7 +3354,7 @@ function providerAuthProfileFromOpts(opts, provider) {
|
|
|
3040
3354
|
return opts.authProfile;
|
|
3041
3355
|
}
|
|
3042
3356
|
var create = program.command("create").description("create loops");
|
|
3043
|
-
addAccountOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell"))).action((name, opts) => {
|
|
3357
|
+
addAccountOptions(addMachineOptions(addScheduleOptions(create.command("command <name>").description("create a deterministic shell command loop").requiredOption("--cmd <command>", "command string to execute").option("--cwd <dir>", "working directory").option("--timeout <duration>", "run timeout").option("--no-shell", "execute without a shell")))).action((name, opts) => {
|
|
3044
3358
|
const store = new Store;
|
|
3045
3359
|
try {
|
|
3046
3360
|
const target = {
|
|
@@ -3057,7 +3371,7 @@ addAccountOptions(addScheduleOptions(create.command("command <name>").descriptio
|
|
|
3057
3371
|
store.close();
|
|
3058
3372
|
}
|
|
3059
3373
|
});
|
|
3060
|
-
addAccountOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe"))).action((name, opts) => {
|
|
3374
|
+
addAccountOptions(addMachineOptions(addScheduleOptions(create.command("agent <name>").description("create a headless coding-agent loop").requiredOption("--provider <provider>", "claude, cursor, codewith, aicopilot, opencode, or codex").requiredOption("--prompt <prompt>", "agent prompt").option("--cwd <dir>", "working directory").option("--model <model>", "model").option("--agent <agent>", "provider-specific agent").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--timeout <duration>", "run timeout").option("--config-isolation <mode>", "safe or none", "safe")))).action((name, opts) => {
|
|
3061
3375
|
const provider = opts.provider;
|
|
3062
3376
|
if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider)) {
|
|
3063
3377
|
throw new Error("unsupported provider");
|
|
@@ -3085,7 +3399,7 @@ addAccountOptions(addScheduleOptions(create.command("agent <name>").description(
|
|
|
3085
3399
|
store.close();
|
|
3086
3400
|
}
|
|
3087
3401
|
});
|
|
3088
|
-
addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name")).action((name, opts) => {
|
|
3402
|
+
addMachineOptions(addScheduleOptions(create.command("workflow <name>").description("schedule a stored workflow").requiredOption("--workflow <idOrName>", "workflow id or name"))).action((name, opts) => {
|
|
3089
3403
|
const store = new Store;
|
|
3090
3404
|
try {
|
|
3091
3405
|
const workflow = store.requireWorkflow(opts.workflow);
|
|
@@ -3100,6 +3414,21 @@ addScheduleOptions(create.command("workflow <name>").description("schedule a sto
|
|
|
3100
3414
|
}
|
|
3101
3415
|
});
|
|
3102
3416
|
var workflows = program.command("workflows").alias("workflow").description("manage workflow specs and runs");
|
|
3417
|
+
var machines = program.command("machines").description("inspect OpenMachines topology for loop assignment");
|
|
3418
|
+
machines.command("list").alias("ls").description("list known machines").action(() => {
|
|
3419
|
+
const values = listOpenMachines();
|
|
3420
|
+
if (isJson())
|
|
3421
|
+
print(values);
|
|
3422
|
+
else {
|
|
3423
|
+
for (const machine of values) {
|
|
3424
|
+
const route = machine.local ? "local" : machine.route ?? "-";
|
|
3425
|
+
console.log(`${machine.id.padEnd(12)} ${route.padEnd(10)} workspace=${machine.workspacePath ?? "-"} host=${machine.hostname ?? "-"}`);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
});
|
|
3429
|
+
machines.command("show <id>").description("resolve a machine assignment").action((id) => {
|
|
3430
|
+
print(resolveLoopMachine(id));
|
|
3431
|
+
});
|
|
3103
3432
|
workflows.command("validate <file>").description("validate a workflow JSON file without storing or running it").option("--name <name>", "override workflow name from the file").option("--preflight", "also check account env and target executables").action((file, opts) => {
|
|
3104
3433
|
const body = workflowBodyFromJson(JSON.parse(readFileSync2(file, "utf8")), opts.name);
|
|
3105
3434
|
const now = new Date().toISOString();
|
|
@@ -3270,7 +3599,8 @@ program.command("list").alias("ls").option("--status <status>", "filter by statu
|
|
|
3270
3599
|
print(loops.map(publicLoop));
|
|
3271
3600
|
else {
|
|
3272
3601
|
for (const loop of loops) {
|
|
3273
|
-
|
|
3602
|
+
const machine = loop.machine ? ` machine=${loop.machine.id}` : "";
|
|
3603
|
+
console.log(`${loop.id} ${loop.status.padEnd(7)} next=${loop.nextRunAt ?? "-"} ${loop.name}${machine}`);
|
|
3274
3604
|
}
|
|
3275
3605
|
}
|
|
3276
3606
|
} finally {
|
|
@@ -3380,9 +3710,9 @@ program.command("doctor").description("check local OpenLoops runtime dependencie
|
|
|
3380
3710
|
const marker = check.status === "ok" ? "ok" : check.status === "warn" ? "warn" : "fail";
|
|
3381
3711
|
console.log(`${marker.padEnd(4)} ${check.id.padEnd(22)} ${check.message}${check.detail ? ` (${check.detail})` : ""}`);
|
|
3382
3712
|
}
|
|
3383
|
-
if (!report.ok)
|
|
3384
|
-
process.exitCode = 1;
|
|
3385
3713
|
}
|
|
3714
|
+
if (!report.ok)
|
|
3715
|
+
process.exitCode = 1;
|
|
3386
3716
|
} finally {
|
|
3387
3717
|
store.close();
|
|
3388
3718
|
}
|