@hasna/loops 0.4.9 → 0.4.10
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/api/index.js +1 -1
- package/dist/cli/index.js +153 -33
- package/dist/daemon/index.js +52 -15
- package/dist/index.js +52 -15
- package/dist/lib/mode.js +1 -1
- package/dist/lib/route/pr-review.d.ts +8 -1
- package/dist/lib/storage/index.js +30 -11
- package/dist/lib/storage/sqlite.js +30 -11
- package/dist/lib/store.js +30 -11
- package/dist/mcp/index.js +52 -15
- package/dist/runner/index.js +22 -4
- package/dist/sdk/index.js +52 -15
- package/package.json +1 -1
package/dist/api/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.10",
|
|
7
7
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8
8
|
type: "module",
|
|
9
9
|
main: "dist/index.js",
|
package/dist/cli/index.js
CHANGED
|
@@ -56,8 +56,12 @@ function nowIso() {
|
|
|
56
56
|
import { mkdirSync } from "fs";
|
|
57
57
|
import { homedir } from "os";
|
|
58
58
|
import { join } from "path";
|
|
59
|
+
function homeDir() {
|
|
60
|
+
const home = process.env.HOME?.trim();
|
|
61
|
+
return home ? home : homedir();
|
|
62
|
+
}
|
|
59
63
|
function dataDir() {
|
|
60
|
-
return process.env.LOOPS_DATA_DIR || join(
|
|
64
|
+
return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
|
|
61
65
|
}
|
|
62
66
|
function ensureDataDir() {
|
|
63
67
|
const dir = dataDir();
|
|
@@ -74,10 +78,10 @@ function daemonLogPath() {
|
|
|
74
78
|
return join(dataDir(), "daemon.log");
|
|
75
79
|
}
|
|
76
80
|
function systemdServicePath() {
|
|
77
|
-
return join(
|
|
81
|
+
return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
|
|
78
82
|
}
|
|
79
83
|
function launchdPlistPath() {
|
|
80
|
-
return join(
|
|
84
|
+
return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// src/lib/process-identity.ts
|
|
@@ -1474,6 +1478,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
|
1474
1478
|
function scrubbedOrNull(value) {
|
|
1475
1479
|
return value == null ? null : scrubSecrets(value);
|
|
1476
1480
|
}
|
|
1481
|
+
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1482
|
+
function clampPersistedRunOutput(value) {
|
|
1483
|
+
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1484
|
+
return value;
|
|
1485
|
+
const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
|
|
1486
|
+
const head = value.slice(0, half);
|
|
1487
|
+
const tail = value.slice(value.length - half);
|
|
1488
|
+
const omitted = value.length - head.length - tail.length;
|
|
1489
|
+
return `${head}
|
|
1490
|
+
\u2026[${omitted} chars truncated by loops run-output retention]\u2026
|
|
1491
|
+
${tail}`;
|
|
1492
|
+
}
|
|
1493
|
+
function persistedRunOutput(value) {
|
|
1494
|
+
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1495
|
+
}
|
|
1477
1496
|
function chmodIfExists(path, mode) {
|
|
1478
1497
|
try {
|
|
1479
1498
|
if (existsSync(path))
|
|
@@ -3220,8 +3239,8 @@ class Store {
|
|
|
3220
3239
|
))`).run({
|
|
3221
3240
|
$workflowRunId: workflowRunId,
|
|
3222
3241
|
$stepId: stepId,
|
|
3223
|
-
$stdout: progress.stdout === undefined ? null :
|
|
3224
|
-
$stderr: progress.stderr === undefined ? null :
|
|
3242
|
+
$stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
|
|
3243
|
+
$stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
|
|
3225
3244
|
$updated: now,
|
|
3226
3245
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3227
3246
|
$now: now
|
|
@@ -3282,8 +3301,8 @@ class Store {
|
|
|
3282
3301
|
$finished: finishedAt,
|
|
3283
3302
|
$exitCode: patch.exitCode ?? null,
|
|
3284
3303
|
$durationMs: patch.durationMs ?? null,
|
|
3285
|
-
$stdout:
|
|
3286
|
-
$stderr:
|
|
3304
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3305
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3287
3306
|
$error: error ?? null,
|
|
3288
3307
|
$updated: finishedAt,
|
|
3289
3308
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
@@ -3661,8 +3680,8 @@ class Store {
|
|
|
3661
3680
|
$pid: patch.pid ?? null,
|
|
3662
3681
|
$exitCode: patch.exitCode ?? null,
|
|
3663
3682
|
$durationMs: patch.durationMs ?? null,
|
|
3664
|
-
$stdout:
|
|
3665
|
-
$stderr:
|
|
3683
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3684
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3666
3685
|
$error: error ?? null,
|
|
3667
3686
|
$updated: finishedAt,
|
|
3668
3687
|
$claimedBy: opts.claimedBy ?? null,
|
|
@@ -4059,8 +4078,8 @@ class Store {
|
|
|
4059
4078
|
$processStartedAt: run.processStartedAt ?? null,
|
|
4060
4079
|
$exitCode: run.exitCode ?? null,
|
|
4061
4080
|
$durationMs: run.durationMs ?? null,
|
|
4062
|
-
$stdout:
|
|
4063
|
-
$stderr:
|
|
4081
|
+
$stdout: persistedRunOutput(run.stdout),
|
|
4082
|
+
$stderr: persistedRunOutput(run.stderr),
|
|
4064
4083
|
$error: scrubbedOrNull(run.error),
|
|
4065
4084
|
$goalRunId: run.goalRunId ?? null,
|
|
4066
4085
|
$created: run.createdAt,
|
|
@@ -4218,7 +4237,7 @@ class Store {
|
|
|
4218
4237
|
// package.json
|
|
4219
4238
|
var package_default = {
|
|
4220
4239
|
name: "@hasna/loops",
|
|
4221
|
-
version: "0.4.
|
|
4240
|
+
version: "0.4.10",
|
|
4222
4241
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4223
4242
|
type: "module",
|
|
4224
4243
|
main: "dist/index.js",
|
|
@@ -4938,6 +4957,20 @@ function notifySpawn(pid, opts) {
|
|
|
4938
4957
|
function shellQuote(value) {
|
|
4939
4958
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
4940
4959
|
}
|
|
4960
|
+
function codewithProfileCandidateFromLine(line) {
|
|
4961
|
+
const cols = line.trim().split(/\s+/);
|
|
4962
|
+
if (cols[0] === "*")
|
|
4963
|
+
return cols[1];
|
|
4964
|
+
return cols[0];
|
|
4965
|
+
}
|
|
4966
|
+
function codewithProfileForError(profile) {
|
|
4967
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
4968
|
+
}
|
|
4969
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
4970
|
+
if (profile.includes("\x00")) {
|
|
4971
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4941
4974
|
function metadataEnv(metadata) {
|
|
4942
4975
|
const env = {};
|
|
4943
4976
|
if (metadata.loopId)
|
|
@@ -5004,6 +5037,9 @@ function commandSpec(target, opts) {
|
|
|
5004
5037
|
};
|
|
5005
5038
|
}
|
|
5006
5039
|
const agentTarget = target;
|
|
5040
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5041
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5042
|
+
}
|
|
5007
5043
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5008
5044
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5009
5045
|
return {
|
|
@@ -5176,7 +5212,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5176
5212
|
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
5177
5213
|
}
|
|
5178
5214
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5179
|
-
|
|
5215
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5216
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
|
|
5180
5217
|
}
|
|
5181
5218
|
return lines.join(`
|
|
5182
5219
|
`);
|
|
@@ -5201,9 +5238,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5201
5238
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5202
5239
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5203
5240
|
}
|
|
5204
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5241
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5205
5242
|
if (!profiles.has(profile)) {
|
|
5206
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5243
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5207
5244
|
}
|
|
5208
5245
|
}
|
|
5209
5246
|
function preflightNativeAuthProfileSync(spec, env) {
|
|
@@ -11376,11 +11413,18 @@ var PR_REVIEW_REQUIRED_FIELDS = [
|
|
|
11376
11413
|
"branch_protection_review_required",
|
|
11377
11414
|
"branchProtectionReviewRequired"
|
|
11378
11415
|
];
|
|
11416
|
+
var GITHUB_USER_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
11379
11417
|
function githubLogin(value) {
|
|
11380
11418
|
if (!value?.trim())
|
|
11381
11419
|
return;
|
|
11382
|
-
|
|
11383
|
-
|
|
11420
|
+
let login = value.trim().replace(/^@/, "");
|
|
11421
|
+
const appActor = /^app\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/.exec(login);
|
|
11422
|
+
if (appActor)
|
|
11423
|
+
login = `${appActor[1]}[bot]`;
|
|
11424
|
+
const bot = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\[bot\]$/.exec(login);
|
|
11425
|
+
if (bot)
|
|
11426
|
+
return `${bot[1]}[bot]`;
|
|
11427
|
+
return GITHUB_USER_LOGIN.test(login) ? login : undefined;
|
|
11384
11428
|
}
|
|
11385
11429
|
function githubLogins(values) {
|
|
11386
11430
|
const seen = new Set;
|
|
@@ -11439,6 +11483,27 @@ function routeTextFieldValues(record, field) {
|
|
|
11439
11483
|
}
|
|
11440
11484
|
return values;
|
|
11441
11485
|
}
|
|
11486
|
+
var PR_STATE_FIELDS = [
|
|
11487
|
+
"pr_state",
|
|
11488
|
+
"prState",
|
|
11489
|
+
"pull_request_state",
|
|
11490
|
+
"pullRequestState",
|
|
11491
|
+
"state",
|
|
11492
|
+
"pr_status",
|
|
11493
|
+
"prStatus"
|
|
11494
|
+
];
|
|
11495
|
+
var CLOSED_PR_STATES = new Set(["MERGED", "CLOSED"]);
|
|
11496
|
+
function normalizePrState(value) {
|
|
11497
|
+
const trimmed = value?.trim().toUpperCase();
|
|
11498
|
+
return trimmed ? trimmed : undefined;
|
|
11499
|
+
}
|
|
11500
|
+
function prStateFromEvidence(records, text) {
|
|
11501
|
+
const field = normalizePrState(firstRouteField(records, PR_STATE_FIELDS));
|
|
11502
|
+
if (field)
|
|
11503
|
+
return field;
|
|
11504
|
+
const match = /\b(?:pr[_\s-]?state|pull[_\s-]?request[_\s-]?state|state)\s*[:=]\s*(MERGED|CLOSED|OPEN)\b/i.exec(text);
|
|
11505
|
+
return match ? match[1].toUpperCase() : undefined;
|
|
11506
|
+
}
|
|
11442
11507
|
function prReferenceFrom(text) {
|
|
11443
11508
|
const url = /github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)/i.exec(text);
|
|
11444
11509
|
if (url)
|
|
@@ -11454,6 +11519,17 @@ function ghAuthorResolver(ref) {
|
|
|
11454
11519
|
return;
|
|
11455
11520
|
return githubLogin((result.stdout ?? "").trim());
|
|
11456
11521
|
}
|
|
11522
|
+
function ghStateResolver(ref) {
|
|
11523
|
+
const result = spawnSync7("gh", ["pr", "view", String(ref.number), "--repo", `${ref.owner}/${ref.repo}`, "--json", "state,mergeStateStatus"], { encoding: "utf8", timeout: 20000 });
|
|
11524
|
+
if (result.error || result.status !== 0)
|
|
11525
|
+
return;
|
|
11526
|
+
try {
|
|
11527
|
+
const parsed = JSON.parse(result.stdout ?? "{}");
|
|
11528
|
+
return { state: parsed.state, mergeStateStatus: parsed.mergeStateStatus };
|
|
11529
|
+
} catch {
|
|
11530
|
+
return;
|
|
11531
|
+
}
|
|
11532
|
+
}
|
|
11457
11533
|
function authorFromPrText(text) {
|
|
11458
11534
|
const patterns = [
|
|
11459
11535
|
/\bauthor\s+(?:is\s+also|is|=|:)\s+@?([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/i,
|
|
@@ -11480,7 +11556,7 @@ function reviewersFromPrText(text) {
|
|
|
11480
11556
|
}
|
|
11481
11557
|
return githubLogins(reviewers);
|
|
11482
11558
|
}
|
|
11483
|
-
function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver) {
|
|
11559
|
+
function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorResolver, resolveState = ghStateResolver) {
|
|
11484
11560
|
const records = [...taskEventRecords(data, metadata), ...automationRecords(data, metadata)];
|
|
11485
11561
|
const text = routeEvidenceText(records);
|
|
11486
11562
|
const signals = [];
|
|
@@ -11500,6 +11576,21 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
|
|
|
11500
11576
|
const required = hasPrReference && (reviewRequiredByField || reviewRequiredByText || mergeBlockedByText || approvalIntent);
|
|
11501
11577
|
if (!required)
|
|
11502
11578
|
return { required: false, allowed: true, reviewers: [], signals };
|
|
11579
|
+
const prRef = prReferenceFrom(text);
|
|
11580
|
+
let prState = prStateFromEvidence(records, text);
|
|
11581
|
+
if (!prState && prRef) {
|
|
11582
|
+
const live = resolveState(prRef);
|
|
11583
|
+
prState = normalizePrState(live?.state);
|
|
11584
|
+
}
|
|
11585
|
+
if (prState && CLOSED_PR_STATES.has(prState)) {
|
|
11586
|
+
return {
|
|
11587
|
+
required: true,
|
|
11588
|
+
allowed: false,
|
|
11589
|
+
reason: `PR is already ${prState.toLowerCase()}; skipping merge/review route (freshness gate)`,
|
|
11590
|
+
reviewers: [],
|
|
11591
|
+
signals: [...signals, "pr-not-open"]
|
|
11592
|
+
};
|
|
11593
|
+
}
|
|
11503
11594
|
const reviewers = githubLogins([
|
|
11504
11595
|
opts.githubReviewer,
|
|
11505
11596
|
...splitList(opts.githubReviewerPool) ?? [],
|
|
@@ -11508,14 +11599,11 @@ function prReviewRoutingDecision(data, metadata, opts, resolveAuthor = ghAuthorR
|
|
|
11508
11599
|
...reviewersFromPrText(text)
|
|
11509
11600
|
]);
|
|
11510
11601
|
let author = githubLogin(firstRouteField(records, PR_AUTHOR_FIELDS)) ?? authorFromPrText(text);
|
|
11511
|
-
if (!author) {
|
|
11512
|
-
const
|
|
11513
|
-
if (
|
|
11514
|
-
|
|
11515
|
-
|
|
11516
|
-
author = derived;
|
|
11517
|
-
signals.push("author-derived-gh");
|
|
11518
|
-
}
|
|
11602
|
+
if (!author && prRef) {
|
|
11603
|
+
const derived = githubLogin(resolveAuthor(prRef));
|
|
11604
|
+
if (derived) {
|
|
11605
|
+
author = derived;
|
|
11606
|
+
signals.push("author-derived-gh");
|
|
11519
11607
|
}
|
|
11520
11608
|
}
|
|
11521
11609
|
const selectedReviewer = author ? reviewers.find((reviewer) => reviewer.toLowerCase() !== author.toLowerCase()) : undefined;
|
|
@@ -11711,6 +11799,34 @@ var UNCLEARED_ROUTE_WORK_ITEM_STATUSES = new Set([
|
|
|
11711
11799
|
function isUnclearedRouteWorkItem(item) {
|
|
11712
11800
|
return UNCLEARED_ROUTE_WORK_ITEM_STATUSES.has(item.status);
|
|
11713
11801
|
}
|
|
11802
|
+
var REACTIVATABLE_TERMINAL_STATUSES = new Set([
|
|
11803
|
+
"succeeded",
|
|
11804
|
+
"failed",
|
|
11805
|
+
"dead_letter",
|
|
11806
|
+
"cancelled"
|
|
11807
|
+
]);
|
|
11808
|
+
var MAX_TODOS_TASK_ROUTE_REDISPATCHES = 8;
|
|
11809
|
+
function todosTaskRouteRedispatchBackoffMs(attempts) {
|
|
11810
|
+
const base = 2 * 60000;
|
|
11811
|
+
const cap = 30 * 60000;
|
|
11812
|
+
const exp = Math.max(0, Math.min(attempts - 1, 10));
|
|
11813
|
+
return Math.min(cap, base * 2 ** exp);
|
|
11814
|
+
}
|
|
11815
|
+
function reactivateStaleTodosTaskWorkItem(store, routeKey, item, now = Date.now()) {
|
|
11816
|
+
if (routeKey !== "todos-task")
|
|
11817
|
+
return;
|
|
11818
|
+
if (!REACTIVATABLE_TERMINAL_STATUSES.has(item.status))
|
|
11819
|
+
return;
|
|
11820
|
+
if (item.attempts >= MAX_TODOS_TASK_ROUTE_REDISPATCHES)
|
|
11821
|
+
return;
|
|
11822
|
+
const finishedAt = Date.parse(item.updatedAt);
|
|
11823
|
+
if (Number.isFinite(finishedAt) && now - finishedAt < todosTaskRouteRedispatchBackoffMs(item.attempts)) {
|
|
11824
|
+
return;
|
|
11825
|
+
}
|
|
11826
|
+
return store.requeueWorkflowWorkItem(item.id, {
|
|
11827
|
+
reason: `re-admitted from ${item.status}: todos task still actionable after prior run (attempt ${item.attempts + 1}/${MAX_TODOS_TASK_ROUTE_REDISPATCHES})`
|
|
11828
|
+
});
|
|
11829
|
+
}
|
|
11714
11830
|
function todosTaskRouteTemplateId(opts) {
|
|
11715
11831
|
const id = (opts.template ?? TODOS_TASK_WORKER_VERIFIER_TEMPLATE_ID).trim();
|
|
11716
11832
|
if (!TODOS_TASK_ROUTE_TEMPLATE_IDS.has(id)) {
|
|
@@ -11803,9 +11919,11 @@ function routeEvent(plan) {
|
|
|
11803
11919
|
const invocation = store.createWorkflowInvocation(plan.invocationInput);
|
|
11804
11920
|
const existingItem = store.findWorkflowWorkItem(plan.routeKey, idempotencyKey);
|
|
11805
11921
|
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
11806
|
-
|
|
11807
|
-
|
|
11808
|
-
|
|
11922
|
+
if (!reactivateStaleTodosTaskWorkItem(store, plan.routeKey, existingItem)) {
|
|
11923
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
11924
|
+
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
11925
|
+
return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
|
|
11926
|
+
}
|
|
11809
11927
|
}
|
|
11810
11928
|
const throttle = hasThrottleLimits(plan.throttleLimits) ? routeThrottleDecision(store, { projectPath: plan.routeProjectPath, projectGroup: plan.projectGroup, limits: plan.throttleLimits }) : undefined;
|
|
11811
11929
|
const workItem = store.upsertWorkflowWorkItem({
|
|
@@ -11937,10 +12055,12 @@ function routeTodosTaskEvent(event, opts) {
|
|
|
11937
12055
|
try {
|
|
11938
12056
|
const existingItem = store.findWorkflowWorkItem("todos-task", idempotencyKey);
|
|
11939
12057
|
if (existingItem && isUnclearedRouteWorkItem(existingItem)) {
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
12058
|
+
if (!reactivateStaleTodosTaskWorkItem(store, "todos-task", existingItem)) {
|
|
12059
|
+
const existingLoop = existingItem.loopId ? store.getLoop(existingItem.loopId) : undefined;
|
|
12060
|
+
const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
|
|
12061
|
+
const existingInvocation = store.getWorkflowInvocation(existingItem.invocationId);
|
|
12062
|
+
return dedupedRoutePrint({ event, idempotencyKey, dedupeValueExtras: {} }, { existingItem, existingLoop, existingWorkflow, invocation: existingInvocation });
|
|
12063
|
+
}
|
|
11944
12064
|
}
|
|
11945
12065
|
} finally {
|
|
11946
12066
|
store.close();
|
package/dist/daemon/index.js
CHANGED
|
@@ -56,8 +56,12 @@ function nowIso() {
|
|
|
56
56
|
import { mkdirSync } from "fs";
|
|
57
57
|
import { homedir } from "os";
|
|
58
58
|
import { join } from "path";
|
|
59
|
+
function homeDir() {
|
|
60
|
+
const home = process.env.HOME?.trim();
|
|
61
|
+
return home ? home : homedir();
|
|
62
|
+
}
|
|
59
63
|
function dataDir() {
|
|
60
|
-
return process.env.LOOPS_DATA_DIR || join(
|
|
64
|
+
return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
|
|
61
65
|
}
|
|
62
66
|
function ensureDataDir() {
|
|
63
67
|
const dir = dataDir();
|
|
@@ -74,10 +78,10 @@ function daemonLogPath() {
|
|
|
74
78
|
return join(dataDir(), "daemon.log");
|
|
75
79
|
}
|
|
76
80
|
function systemdServicePath() {
|
|
77
|
-
return join(
|
|
81
|
+
return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
|
|
78
82
|
}
|
|
79
83
|
function launchdPlistPath() {
|
|
80
|
-
return join(
|
|
84
|
+
return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
// src/lib/process-identity.ts
|
|
@@ -1474,6 +1478,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
|
1474
1478
|
function scrubbedOrNull(value) {
|
|
1475
1479
|
return value == null ? null : scrubSecrets(value);
|
|
1476
1480
|
}
|
|
1481
|
+
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1482
|
+
function clampPersistedRunOutput(value) {
|
|
1483
|
+
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1484
|
+
return value;
|
|
1485
|
+
const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
|
|
1486
|
+
const head = value.slice(0, half);
|
|
1487
|
+
const tail = value.slice(value.length - half);
|
|
1488
|
+
const omitted = value.length - head.length - tail.length;
|
|
1489
|
+
return `${head}
|
|
1490
|
+
\u2026[${omitted} chars truncated by loops run-output retention]\u2026
|
|
1491
|
+
${tail}`;
|
|
1492
|
+
}
|
|
1493
|
+
function persistedRunOutput(value) {
|
|
1494
|
+
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1495
|
+
}
|
|
1477
1496
|
function chmodIfExists(path, mode) {
|
|
1478
1497
|
try {
|
|
1479
1498
|
if (existsSync(path))
|
|
@@ -3220,8 +3239,8 @@ class Store {
|
|
|
3220
3239
|
))`).run({
|
|
3221
3240
|
$workflowRunId: workflowRunId,
|
|
3222
3241
|
$stepId: stepId,
|
|
3223
|
-
$stdout: progress.stdout === undefined ? null :
|
|
3224
|
-
$stderr: progress.stderr === undefined ? null :
|
|
3242
|
+
$stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
|
|
3243
|
+
$stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
|
|
3225
3244
|
$updated: now,
|
|
3226
3245
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3227
3246
|
$now: now
|
|
@@ -3282,8 +3301,8 @@ class Store {
|
|
|
3282
3301
|
$finished: finishedAt,
|
|
3283
3302
|
$exitCode: patch.exitCode ?? null,
|
|
3284
3303
|
$durationMs: patch.durationMs ?? null,
|
|
3285
|
-
$stdout:
|
|
3286
|
-
$stderr:
|
|
3304
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3305
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3287
3306
|
$error: error ?? null,
|
|
3288
3307
|
$updated: finishedAt,
|
|
3289
3308
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
@@ -3661,8 +3680,8 @@ class Store {
|
|
|
3661
3680
|
$pid: patch.pid ?? null,
|
|
3662
3681
|
$exitCode: patch.exitCode ?? null,
|
|
3663
3682
|
$durationMs: patch.durationMs ?? null,
|
|
3664
|
-
$stdout:
|
|
3665
|
-
$stderr:
|
|
3683
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3684
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3666
3685
|
$error: error ?? null,
|
|
3667
3686
|
$updated: finishedAt,
|
|
3668
3687
|
$claimedBy: opts.claimedBy ?? null,
|
|
@@ -4059,8 +4078,8 @@ class Store {
|
|
|
4059
4078
|
$processStartedAt: run.processStartedAt ?? null,
|
|
4060
4079
|
$exitCode: run.exitCode ?? null,
|
|
4061
4080
|
$durationMs: run.durationMs ?? null,
|
|
4062
|
-
$stdout:
|
|
4063
|
-
$stderr:
|
|
4081
|
+
$stdout: persistedRunOutput(run.stdout),
|
|
4082
|
+
$stderr: persistedRunOutput(run.stderr),
|
|
4064
4083
|
$error: scrubbedOrNull(run.error),
|
|
4065
4084
|
$goalRunId: run.goalRunId ?? null,
|
|
4066
4085
|
$created: run.createdAt,
|
|
@@ -5099,6 +5118,20 @@ function notifySpawn(pid, opts) {
|
|
|
5099
5118
|
function shellQuote(value) {
|
|
5100
5119
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5101
5120
|
}
|
|
5121
|
+
function codewithProfileCandidateFromLine(line) {
|
|
5122
|
+
const cols = line.trim().split(/\s+/);
|
|
5123
|
+
if (cols[0] === "*")
|
|
5124
|
+
return cols[1];
|
|
5125
|
+
return cols[0];
|
|
5126
|
+
}
|
|
5127
|
+
function codewithProfileForError(profile) {
|
|
5128
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
5129
|
+
}
|
|
5130
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
5131
|
+
if (profile.includes("\x00")) {
|
|
5132
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
5133
|
+
}
|
|
5134
|
+
}
|
|
5102
5135
|
function metadataEnv(metadata) {
|
|
5103
5136
|
const env = {};
|
|
5104
5137
|
if (metadata.loopId)
|
|
@@ -5165,6 +5198,9 @@ function commandSpec(target, opts) {
|
|
|
5165
5198
|
};
|
|
5166
5199
|
}
|
|
5167
5200
|
const agentTarget = target;
|
|
5201
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5202
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5203
|
+
}
|
|
5168
5204
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5169
5205
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5170
5206
|
return {
|
|
@@ -5337,7 +5373,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5337
5373
|
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
5338
5374
|
}
|
|
5339
5375
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5340
|
-
|
|
5376
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5377
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
|
|
5341
5378
|
}
|
|
5342
5379
|
return lines.join(`
|
|
5343
5380
|
`);
|
|
@@ -5362,9 +5399,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5362
5399
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5363
5400
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5364
5401
|
}
|
|
5365
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5402
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5366
5403
|
if (!profiles.has(profile)) {
|
|
5367
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5404
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5368
5405
|
}
|
|
5369
5406
|
}
|
|
5370
5407
|
function preflightNativeAuthProfileSync(spec, env) {
|
|
@@ -7715,7 +7752,7 @@ function enableStartup(result) {
|
|
|
7715
7752
|
// package.json
|
|
7716
7753
|
var package_default = {
|
|
7717
7754
|
name: "@hasna/loops",
|
|
7718
|
-
version: "0.4.
|
|
7755
|
+
version: "0.4.10",
|
|
7719
7756
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7720
7757
|
type: "module",
|
|
7721
7758
|
main: "dist/index.js",
|
package/dist/index.js
CHANGED
|
@@ -54,8 +54,12 @@ function nowIso() {
|
|
|
54
54
|
import { mkdirSync } from "fs";
|
|
55
55
|
import { homedir } from "os";
|
|
56
56
|
import { join } from "path";
|
|
57
|
+
function homeDir() {
|
|
58
|
+
const home = process.env.HOME?.trim();
|
|
59
|
+
return home ? home : homedir();
|
|
60
|
+
}
|
|
57
61
|
function dataDir() {
|
|
58
|
-
return process.env.LOOPS_DATA_DIR || join(
|
|
62
|
+
return process.env.LOOPS_DATA_DIR || join(homeDir(), ".hasna", "loops");
|
|
59
63
|
}
|
|
60
64
|
function ensureDataDir() {
|
|
61
65
|
const dir = dataDir();
|
|
@@ -72,10 +76,10 @@ function daemonLogPath() {
|
|
|
72
76
|
return join(dataDir(), "daemon.log");
|
|
73
77
|
}
|
|
74
78
|
function systemdServicePath() {
|
|
75
|
-
return join(
|
|
79
|
+
return join(homeDir(), ".config", "systemd", "user", "loops-daemon.service");
|
|
76
80
|
}
|
|
77
81
|
function launchdPlistPath() {
|
|
78
|
-
return join(
|
|
82
|
+
return join(homeDir(), "Library", "LaunchAgents", "com.hasna.loops.daemon.plist");
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
// src/lib/process-identity.ts
|
|
@@ -1472,6 +1476,21 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
|
|
|
1472
1476
|
function scrubbedOrNull(value) {
|
|
1473
1477
|
return value == null ? null : scrubSecrets(value);
|
|
1474
1478
|
}
|
|
1479
|
+
var MAX_PERSISTED_RUN_OUTPUT_CHARS = 64 * 1024;
|
|
1480
|
+
function clampPersistedRunOutput(value) {
|
|
1481
|
+
if (value == null || value.length <= MAX_PERSISTED_RUN_OUTPUT_CHARS)
|
|
1482
|
+
return value;
|
|
1483
|
+
const half = Math.floor(MAX_PERSISTED_RUN_OUTPUT_CHARS / 2);
|
|
1484
|
+
const head = value.slice(0, half);
|
|
1485
|
+
const tail = value.slice(value.length - half);
|
|
1486
|
+
const omitted = value.length - head.length - tail.length;
|
|
1487
|
+
return `${head}
|
|
1488
|
+
\u2026[${omitted} chars truncated by loops run-output retention]\u2026
|
|
1489
|
+
${tail}`;
|
|
1490
|
+
}
|
|
1491
|
+
function persistedRunOutput(value) {
|
|
1492
|
+
return clampPersistedRunOutput(scrubbedOrNull(value));
|
|
1493
|
+
}
|
|
1475
1494
|
function chmodIfExists(path, mode) {
|
|
1476
1495
|
try {
|
|
1477
1496
|
if (existsSync(path))
|
|
@@ -3218,8 +3237,8 @@ class Store {
|
|
|
3218
3237
|
))`).run({
|
|
3219
3238
|
$workflowRunId: workflowRunId,
|
|
3220
3239
|
$stepId: stepId,
|
|
3221
|
-
$stdout: progress.stdout === undefined ? null :
|
|
3222
|
-
$stderr: progress.stderr === undefined ? null :
|
|
3240
|
+
$stdout: progress.stdout === undefined ? null : persistedRunOutput(progress.stdout),
|
|
3241
|
+
$stderr: progress.stderr === undefined ? null : persistedRunOutput(progress.stderr),
|
|
3223
3242
|
$updated: now,
|
|
3224
3243
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
3225
3244
|
$now: now
|
|
@@ -3280,8 +3299,8 @@ class Store {
|
|
|
3280
3299
|
$finished: finishedAt,
|
|
3281
3300
|
$exitCode: patch.exitCode ?? null,
|
|
3282
3301
|
$durationMs: patch.durationMs ?? null,
|
|
3283
|
-
$stdout:
|
|
3284
|
-
$stderr:
|
|
3302
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3303
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3285
3304
|
$error: error ?? null,
|
|
3286
3305
|
$updated: finishedAt,
|
|
3287
3306
|
$daemonLeaseId: opts.daemonLeaseId ?? null,
|
|
@@ -3659,8 +3678,8 @@ class Store {
|
|
|
3659
3678
|
$pid: patch.pid ?? null,
|
|
3660
3679
|
$exitCode: patch.exitCode ?? null,
|
|
3661
3680
|
$durationMs: patch.durationMs ?? null,
|
|
3662
|
-
$stdout:
|
|
3663
|
-
$stderr:
|
|
3681
|
+
$stdout: persistedRunOutput(patch.stdout),
|
|
3682
|
+
$stderr: persistedRunOutput(patch.stderr),
|
|
3664
3683
|
$error: error ?? null,
|
|
3665
3684
|
$updated: finishedAt,
|
|
3666
3685
|
$claimedBy: opts.claimedBy ?? null,
|
|
@@ -4057,8 +4076,8 @@ class Store {
|
|
|
4057
4076
|
$processStartedAt: run.processStartedAt ?? null,
|
|
4058
4077
|
$exitCode: run.exitCode ?? null,
|
|
4059
4078
|
$durationMs: run.durationMs ?? null,
|
|
4060
|
-
$stdout:
|
|
4061
|
-
$stderr:
|
|
4079
|
+
$stdout: persistedRunOutput(run.stdout),
|
|
4080
|
+
$stderr: persistedRunOutput(run.stderr),
|
|
4062
4081
|
$error: scrubbedOrNull(run.error),
|
|
4063
4082
|
$goalRunId: run.goalRunId ?? null,
|
|
4064
4083
|
$created: run.createdAt,
|
|
@@ -4216,7 +4235,7 @@ class Store {
|
|
|
4216
4235
|
// package.json
|
|
4217
4236
|
var package_default = {
|
|
4218
4237
|
name: "@hasna/loops",
|
|
4219
|
-
version: "0.4.
|
|
4238
|
+
version: "0.4.10",
|
|
4220
4239
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
4221
4240
|
type: "module",
|
|
4222
4241
|
main: "dist/index.js",
|
|
@@ -5045,6 +5064,20 @@ function notifySpawn(pid, opts) {
|
|
|
5045
5064
|
function shellQuote(value) {
|
|
5046
5065
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
5047
5066
|
}
|
|
5067
|
+
function codewithProfileCandidateFromLine(line) {
|
|
5068
|
+
const cols = line.trim().split(/\s+/);
|
|
5069
|
+
if (cols[0] === "*")
|
|
5070
|
+
return cols[1];
|
|
5071
|
+
return cols[0];
|
|
5072
|
+
}
|
|
5073
|
+
function codewithProfileForError(profile) {
|
|
5074
|
+
return /[\u0000-\u001F\u007F]/.test(profile) ? JSON.stringify(profile) ?? profile : profile;
|
|
5075
|
+
}
|
|
5076
|
+
function assertCodewithAuthProfileSupported(profile) {
|
|
5077
|
+
if (profile.includes("\x00")) {
|
|
5078
|
+
throw new Error(`codewith auth profile contains unsupported NUL byte: ${codewithProfileForError(profile)}`);
|
|
5079
|
+
}
|
|
5080
|
+
}
|
|
5048
5081
|
function metadataEnv(metadata) {
|
|
5049
5082
|
const env = {};
|
|
5050
5083
|
if (metadata.loopId)
|
|
@@ -5111,6 +5144,9 @@ function commandSpec(target, opts) {
|
|
|
5111
5144
|
};
|
|
5112
5145
|
}
|
|
5113
5146
|
const agentTarget = target;
|
|
5147
|
+
if (agentTarget.provider === "codewith" && agentTarget.authProfile) {
|
|
5148
|
+
assertCodewithAuthProfileSupported(agentTarget.authProfile);
|
|
5149
|
+
}
|
|
5114
5150
|
const adapter = providerAdapter(agentTarget.provider);
|
|
5115
5151
|
const invocation = adapter.buildInvocation(agentTarget);
|
|
5116
5152
|
return {
|
|
@@ -5283,7 +5319,8 @@ function remotePreflightScript(spec, metadata) {
|
|
|
5283
5319
|
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
5284
5320
|
}
|
|
5285
5321
|
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
5286
|
-
|
|
5322
|
+
const profileForError = codewithProfileForError(spec.nativeAuthProfile.profile);
|
|
5323
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILE=${shellQuote(spec.nativeAuthProfile.profile)}`, "export __OPENLOOPS_CODEWITH_PROFILE", `__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { candidate = ($1 == "*" ? $2 : $1); if (candidate == ENVIRON["__OPENLOOPS_CODEWITH_PROFILE"]) found = 1 } END { exit(found ? 0 : 1) }'; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${profileForError}`)} >&2`, " exit 1", "fi");
|
|
5287
5324
|
}
|
|
5288
5325
|
return lines.join(`
|
|
5289
5326
|
`);
|
|
@@ -5308,9 +5345,9 @@ function assertCodewithProfileListed(profile, result) {
|
|
|
5308
5345
|
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
5309
5346
|
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
5310
5347
|
}
|
|
5311
|
-
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(
|
|
5348
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map(codewithProfileCandidateFromLine).filter(Boolean));
|
|
5312
5349
|
if (!profiles.has(profile)) {
|
|
5313
|
-
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
5350
|
+
throw new Error(`codewith auth profile not found: ${codewithProfileForError(profile)}`);
|
|
5314
5351
|
}
|
|
5315
5352
|
}
|
|
5316
5353
|
function preflightNativeAuthProfileSync(spec, env) {
|
package/dist/lib/mode.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// package.json
|
|
3
3
|
var package_default = {
|
|
4
4
|
name: "@hasna/loops",
|
|
5
|
-
version: "0.4.
|
|
5
|
+
version: "0.4.10",
|
|
6
6
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
7
7
|
type: "module",
|
|
8
8
|
main: "dist/index.js",
|