@kyleroche/relay 0.1.0 → 0.2.0
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/LICENSE +1 -1
- package/dist/container/Dockerfile +66 -0
- package/dist/container/relay-entry +31 -0
- package/dist/hooks/statusline.mjs +97 -0
- package/dist/migrations/008_queue_state.sql +13 -0
- package/dist/migrations/009_worker_meters.sql +5 -0
- package/dist/migrations/010_task_state.sql +20 -0
- package/dist/relay-mcp.js +2367 -636
- package/dist/relay.js +3686 -1485
- package/package.json +5 -4
package/dist/relay-mcp.js
CHANGED
|
@@ -133,6 +133,15 @@ function httpClient(opts) {
|
|
|
133
133
|
async putSummary(slug, summary, tokens, o = {}) {
|
|
134
134
|
await rest("PUT", `/projects/${encodeURIComponent(slug)}/summary`, { summary, tokens, usage: o.usage ?? [] });
|
|
135
135
|
},
|
|
136
|
+
async githubStatus(slug) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await rest("GET", `/projects/${encodeURIComponent(slug)}/github`);
|
|
139
|
+
return r.body;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e instanceof RelayHttpError && e.status === 404) return void 0;
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
136
145
|
async recall(q) {
|
|
137
146
|
const text2 = await tool("relay_recall", { query: q.query, ...q.project ? { project: q.project } : {} });
|
|
138
147
|
return { preferences: [], decisions: [], text: /^No matching/.test(text2) ? "" : text2 };
|
|
@@ -213,6 +222,22 @@ ${q.issue.body.slice(0, 2e3)}` : ""}` : q.intent, max_tokens: Math.max(200, Math
|
|
|
213
222
|
const r = await rest("GET", "/tasks", void 0, { project: o.project, state: o.state, limit: o.limit });
|
|
214
223
|
return r.body.tasks;
|
|
215
224
|
},
|
|
225
|
+
async approveTask(id) {
|
|
226
|
+
const r = await rest("POST", `/tasks/${taskPath(id)}/approve`, {});
|
|
227
|
+
return r.body;
|
|
228
|
+
},
|
|
229
|
+
async requestChanges(id, note) {
|
|
230
|
+
const r = await rest("POST", `/tasks/${taskPath(id)}/request-changes`, { note });
|
|
231
|
+
return r.body;
|
|
232
|
+
},
|
|
233
|
+
async requeueTask(id, o = {}) {
|
|
234
|
+
const r = await rest("POST", `/tasks/${taskPath(id)}/requeue`, { ...o.lane ? { lane: o.lane } : {}, ...o.reason ? { reason: o.reason } : {} });
|
|
235
|
+
return r.body;
|
|
236
|
+
},
|
|
237
|
+
async releaseTask(id, o = {}) {
|
|
238
|
+
const r = await rest("POST", `/tasks/${taskPath(id)}/release`, { ...o.note ? { note: o.note } : {} });
|
|
239
|
+
return r.body;
|
|
240
|
+
},
|
|
216
241
|
async addWebhook(input) {
|
|
217
242
|
const r = await rest("POST", "/webhooks", input);
|
|
218
243
|
return r.body;
|
|
@@ -422,9 +447,11 @@ function packageAt(root) {
|
|
|
422
447
|
sessionStart: join3(hooksDir, "session-start.mjs"),
|
|
423
448
|
userPrompt: join3(hooksDir, "user-prompt.mjs"),
|
|
424
449
|
sessionEnd: join3(hooksDir, "session-end.mjs"),
|
|
450
|
+
statusLine: join3(hooksDir, "statusline.mjs"),
|
|
425
451
|
mcpEntry: join3(dist, "relay-mcp.js"),
|
|
426
452
|
templatesDir: join3(root, "templates"),
|
|
427
|
-
migrationsDir: join3(dist, "migrations")
|
|
453
|
+
migrationsDir: join3(dist, "migrations"),
|
|
454
|
+
containerDir: join3(dist, "container")
|
|
428
455
|
};
|
|
429
456
|
}
|
|
430
457
|
function checkoutAt(root) {
|
|
@@ -437,9 +464,11 @@ function checkoutAt(root) {
|
|
|
437
464
|
sessionStart: join3(hooksDir, "session-start.mjs"),
|
|
438
465
|
userPrompt: join3(hooksDir, "user-prompt.mjs"),
|
|
439
466
|
sessionEnd: join3(hooksDir, "session-end.mjs"),
|
|
467
|
+
statusLine: join3(hooksDir, "statusline.mjs"),
|
|
440
468
|
mcpEntry: join3(root, "packages", "mcp", "bin", "relay-mcp.mjs"),
|
|
441
469
|
templatesDir: join3(root, "packages", "cli", "templates"),
|
|
442
|
-
migrationsDir: join3(root, "packages", "core", "src", "db", "migrations")
|
|
470
|
+
migrationsDir: join3(root, "packages", "core", "src", "db", "migrations"),
|
|
471
|
+
containerDir: join3(root, "packages", "cli", "container")
|
|
443
472
|
};
|
|
444
473
|
}
|
|
445
474
|
function detectLayout(root) {
|
|
@@ -461,11 +490,45 @@ function runtimePaths(root) {
|
|
|
461
490
|
return selfRoot();
|
|
462
491
|
}
|
|
463
492
|
|
|
493
|
+
// ../core/src/meters.ts
|
|
494
|
+
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3 } from "fs";
|
|
495
|
+
import { join as join4 } from "path";
|
|
496
|
+
var METER_WINDOWS = ["5h", "7d"];
|
|
497
|
+
var METER_SOURCE = "claude-statusline";
|
|
498
|
+
var METERS_MAX_AGE_MS = 48 * 36e5;
|
|
499
|
+
var pct = (v) => typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.min(100, Math.round(v))) : void 0;
|
|
500
|
+
function resetsAtIso(v) {
|
|
501
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return new Date(v * 1e3).toISOString();
|
|
502
|
+
if (typeof v === "string" && !Number.isNaN(Date.parse(v))) return new Date(v).toISOString();
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
function sanitizeMeters(input) {
|
|
506
|
+
if (input === void 0) return void 0;
|
|
507
|
+
if (!Array.isArray(input)) return [];
|
|
508
|
+
const out = [];
|
|
509
|
+
for (const r of input.slice(0, 20)) {
|
|
510
|
+
if (!r || typeof r !== "object") continue;
|
|
511
|
+
const used = pct(r.used_percentage);
|
|
512
|
+
const window = r.window;
|
|
513
|
+
if (used === void 0 || !METER_WINDOWS.includes(window)) continue;
|
|
514
|
+
if (typeof r.captured_at !== "string" || Number.isNaN(Date.parse(r.captured_at))) continue;
|
|
515
|
+
out.push({
|
|
516
|
+
subscription: typeof r.subscription === "string" && r.subscription.trim() ? r.subscription.trim().slice(0, 32) : "max",
|
|
517
|
+
window,
|
|
518
|
+
used_percentage: used,
|
|
519
|
+
resets_at: resetsAtIso(r.resets_at),
|
|
520
|
+
captured_at: new Date(r.captured_at).toISOString(),
|
|
521
|
+
source: METER_SOURCE
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
464
527
|
// ../core/src/db/client.ts
|
|
465
528
|
import { mkdirSync as mkdirSync3 } from "fs";
|
|
466
529
|
import { homedir as homedir3 } from "os";
|
|
467
|
-
import { dirname as dirname2, join as
|
|
468
|
-
var DEFAULT_PGLITE_FILE =
|
|
530
|
+
import { dirname as dirname2, join as join5 } from "path";
|
|
531
|
+
var DEFAULT_PGLITE_FILE = join5(process.env.RELAY_HOME ?? join5(homedir3(), ".harness"), "relay.pglite");
|
|
469
532
|
var DbError = class extends Error {
|
|
470
533
|
constructor(message2, sql) {
|
|
471
534
|
super(message2);
|
|
@@ -563,9 +626,496 @@ var num = (v) => v === null || v === void 0 ? 0 : typeof v === "number" ? v : Nu
|
|
|
563
626
|
var iso = (v) => v === null || v === void 0 ? null : v instanceof Date ? v.toISOString() : String(v);
|
|
564
627
|
var vec = (v) => `[${v.join(",")}]`;
|
|
565
628
|
|
|
629
|
+
// ../core/src/db/queue.ts
|
|
630
|
+
async function getQueueVersion(db) {
|
|
631
|
+
const r = await db.query("SELECT version FROM queue_state WHERE one");
|
|
632
|
+
return r[0] ? num(r[0].version) : 0;
|
|
633
|
+
}
|
|
634
|
+
async function bumpQueueVersion(db) {
|
|
635
|
+
const r = await db.query(
|
|
636
|
+
`INSERT INTO queue_state (one, version, updated_at) VALUES (true, 1, now())
|
|
637
|
+
ON CONFLICT (one) DO UPDATE SET version = queue_state.version + 1, updated_at = now() RETURNING version`
|
|
638
|
+
);
|
|
639
|
+
return num(r[0]?.version);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ../core/src/db/tasks.ts
|
|
643
|
+
var TASK_STATES = ["queued", "held", "claimed", "running", "review", "done", "failed"];
|
|
644
|
+
var isTaskState = (s) => TASK_STATES.includes(s);
|
|
645
|
+
var taskId = (repo, issue) => `${repo}#${issue}`;
|
|
646
|
+
var issueUrl = (repo, issue) => `https://github.com/${repo}/issues/${issue}`;
|
|
647
|
+
function parseTaskId(id) {
|
|
648
|
+
const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(id.trim());
|
|
649
|
+
return m ? { repo: m[1], issue: Number(m[2]) } : void 0;
|
|
650
|
+
}
|
|
651
|
+
var cols = "id, project, repo, issue, idempotency_key, created_by, created_at, lane, title";
|
|
652
|
+
var toTask = (r) => ({
|
|
653
|
+
id: r.id,
|
|
654
|
+
project: r.project,
|
|
655
|
+
repo: r.repo,
|
|
656
|
+
issue: num(r.issue),
|
|
657
|
+
idempotency_key: r.idempotency_key ?? null,
|
|
658
|
+
created_by: r.created_by,
|
|
659
|
+
created_at: iso(r.created_at) ?? "",
|
|
660
|
+
lane: r.lane ?? null,
|
|
661
|
+
title: r.title ?? null
|
|
662
|
+
});
|
|
663
|
+
var TASK_COLS = cols;
|
|
664
|
+
async function findTaskByKey(db, key) {
|
|
665
|
+
const r = await db.query(`SELECT ${cols} FROM tasks WHERE idempotency_key = $1`, [key]);
|
|
666
|
+
return r[0] ? toTask(r[0]) : void 0;
|
|
667
|
+
}
|
|
668
|
+
async function getTask(db, id) {
|
|
669
|
+
const r = await db.query(`SELECT ${cols} FROM tasks WHERE id = $1`, [id]);
|
|
670
|
+
return r[0] ? toTask(r[0]) : void 0;
|
|
671
|
+
}
|
|
672
|
+
async function createTask(db, t) {
|
|
673
|
+
const id = taskId(t.repo, t.issue);
|
|
674
|
+
const key = t.idempotencyKey?.trim() || null;
|
|
675
|
+
const title = t.title?.trim() || null;
|
|
676
|
+
if (key) {
|
|
677
|
+
const existing2 = await findTaskByKey(db, key);
|
|
678
|
+
if (existing2) return { task: existing2, created: false };
|
|
679
|
+
}
|
|
680
|
+
const r = await db.query(
|
|
681
|
+
`INSERT INTO tasks (id, project, repo, issue, idempotency_key, created_by, lane, title) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
682
|
+
ON CONFLICT (id) DO NOTHING RETURNING ${cols}`,
|
|
683
|
+
[id, t.project, t.repo, t.issue, key, t.createdBy, t.lane ?? null, title]
|
|
684
|
+
);
|
|
685
|
+
if (r[0]) {
|
|
686
|
+
await refreshTaskState(db, id);
|
|
687
|
+
await bumpQueueVersion(db);
|
|
688
|
+
return { task: toTask(r[0]), created: true };
|
|
689
|
+
}
|
|
690
|
+
const existing = await getTask(db, id);
|
|
691
|
+
if (!existing) throw new Error(`task ${id} could not be created`);
|
|
692
|
+
if (t.lane && existing.lane !== t.lane) {
|
|
693
|
+
await db.query("UPDATE tasks SET lane = $2 WHERE id = $1", [id, t.lane]);
|
|
694
|
+
existing.lane = t.lane;
|
|
695
|
+
await bumpQueueVersion(db);
|
|
696
|
+
}
|
|
697
|
+
if (title && !existing.title) {
|
|
698
|
+
await db.query("UPDATE tasks SET title = $2 WHERE id = $1", [id, title]);
|
|
699
|
+
existing.title = title;
|
|
700
|
+
}
|
|
701
|
+
return { task: existing, created: false };
|
|
702
|
+
}
|
|
703
|
+
async function listTasks(db, opts = {}) {
|
|
704
|
+
const params = [];
|
|
705
|
+
const where = opts.project ? (params.push(opts.project), `WHERE project = $1`) : "";
|
|
706
|
+
params.push(opts.limit ?? 50);
|
|
707
|
+
const r = await db.query(`SELECT ${cols} FROM tasks ${where} ORDER BY created_at DESC, id DESC LIMIT $${params.length}`, params);
|
|
708
|
+
return r.map(toTask);
|
|
709
|
+
}
|
|
710
|
+
var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "startup_failure", "action_required"]);
|
|
711
|
+
var relayBranchPattern = (issue) => new RegExp(`^relay/(?:[a-z][a-z0-9-]*-${issue}|${issue}-[a-z][a-z0-9-]*)(?:$|[-/])`);
|
|
712
|
+
var payloadStr = (e, key) => typeof e.payload[key] === "string" ? e.payload[key] : null;
|
|
713
|
+
function previewMatches(task, e, prs, branch) {
|
|
714
|
+
if (!e.kind.startsWith("preview.")) return false;
|
|
715
|
+
if (e.issue != null) return e.issue === task.issue;
|
|
716
|
+
if (e.pr != null && prs.has(e.pr)) return true;
|
|
717
|
+
const ref = payloadStr(e, "ref") ?? payloadStr(e, "branch");
|
|
718
|
+
if (!ref) return false;
|
|
719
|
+
if (branch && ref === branch) return true;
|
|
720
|
+
return relayBranchPattern(task.issue).test(ref);
|
|
721
|
+
}
|
|
722
|
+
function deriveTaskStatus(task, events, claims = [], now = Date.now()) {
|
|
723
|
+
const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts) || a.id - b.id);
|
|
724
|
+
let state = "queued";
|
|
725
|
+
let runner = null;
|
|
726
|
+
let lane = null;
|
|
727
|
+
let pr = null;
|
|
728
|
+
let pr_url = null;
|
|
729
|
+
let branch = null;
|
|
730
|
+
let lastStart = null;
|
|
731
|
+
let lastEnd = null;
|
|
732
|
+
let run_summary = null;
|
|
733
|
+
let usage = null;
|
|
734
|
+
let merged_at = null;
|
|
735
|
+
let review = { decided: null, by: null, at: null, note: null };
|
|
736
|
+
const prs = /* @__PURE__ */ new Set();
|
|
737
|
+
for (const e of sorted) if (e.pr != null && !e.kind.startsWith("preview.")) prs.add(e.pr);
|
|
738
|
+
let preview = null;
|
|
739
|
+
let lastPush = null;
|
|
740
|
+
const matched = [];
|
|
741
|
+
for (const e of sorted) {
|
|
742
|
+
if (e.kind.startsWith("preview.")) {
|
|
743
|
+
if (previewMatches(task, e, prs, branch)) {
|
|
744
|
+
matched.push(e);
|
|
745
|
+
if (e.kind === "preview.ready") preview = { url: e.url, state: "ready", ts: e.ts };
|
|
746
|
+
else if (e.kind === "preview.failed") preview = { url: preview?.url ?? null, state: "failed", ts: e.ts };
|
|
747
|
+
}
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const gateRow = e.kind === "step.usage" && e.lane === "gate";
|
|
751
|
+
if (e.lane && !gateRow) lane = e.lane;
|
|
752
|
+
if (e.pr != null) pr = e.pr;
|
|
753
|
+
const b = payloadStr(e, "branch");
|
|
754
|
+
if (b && (e.kind === "run.started" || e.kind === "pr.opened")) branch = b;
|
|
755
|
+
switch (e.kind) {
|
|
756
|
+
case "issue.labeled":
|
|
757
|
+
state = "queued";
|
|
758
|
+
break;
|
|
759
|
+
case "run.started":
|
|
760
|
+
state = "running";
|
|
761
|
+
lastStart = e.ts;
|
|
762
|
+
if (e.actor.startsWith("runner:")) runner = e.actor;
|
|
763
|
+
break;
|
|
764
|
+
case "run.dispatched":
|
|
765
|
+
state = "running";
|
|
766
|
+
break;
|
|
767
|
+
case "run.failed":
|
|
768
|
+
state = "failed";
|
|
769
|
+
lastEnd = e.ts;
|
|
770
|
+
break;
|
|
771
|
+
case "run.finished": {
|
|
772
|
+
const conclusion = typeof e.payload.conclusion === "string" ? e.payload.conclusion : "";
|
|
773
|
+
state = FAILED_CONCLUSIONS.has(conclusion) ? "failed" : "review";
|
|
774
|
+
lastEnd = e.ts;
|
|
775
|
+
if (e.actor.startsWith("runner:")) runner = e.actor;
|
|
776
|
+
if (state === "review") {
|
|
777
|
+
run_summary = e.summary || run_summary;
|
|
778
|
+
lastPush = e.ts;
|
|
779
|
+
if (review.decided === "changes_requested") review = { decided: null, by: null, at: null, note: null };
|
|
780
|
+
}
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
case "step.usage":
|
|
784
|
+
usage = {
|
|
785
|
+
runs: (usage?.runs ?? 0) + (gateRow ? 0 : 1),
|
|
786
|
+
tokens_in: (usage?.tokens_in ?? 0) + e.tokens_in,
|
|
787
|
+
tokens_out: (usage?.tokens_out ?? 0) + e.tokens_out,
|
|
788
|
+
cache_read: (usage?.cache_read ?? 0) + e.cache_read,
|
|
789
|
+
cost_equiv: Math.round(((usage?.cost_equiv ?? 0) + e.cost_equiv) * 1e6) / 1e6,
|
|
790
|
+
model: gateRow ? usage?.model ?? null : e.model ?? usage?.model ?? null,
|
|
791
|
+
duration_ms: gateRow ? usage?.duration_ms ?? null : e.duration_ms ?? usage?.duration_ms ?? null
|
|
792
|
+
};
|
|
793
|
+
break;
|
|
794
|
+
case "pr.opened":
|
|
795
|
+
state = "review";
|
|
796
|
+
if (e.url) pr_url = e.url;
|
|
797
|
+
lastPush = e.ts;
|
|
798
|
+
break;
|
|
799
|
+
case "pr.merged":
|
|
800
|
+
case "issue.closed":
|
|
801
|
+
state = "done";
|
|
802
|
+
if (e.kind === "pr.merged") {
|
|
803
|
+
if (e.url) pr_url = e.url;
|
|
804
|
+
merged_at = merged_at ?? e.ts;
|
|
805
|
+
}
|
|
806
|
+
break;
|
|
807
|
+
case "pr.closed":
|
|
808
|
+
state = "queued";
|
|
809
|
+
break;
|
|
810
|
+
case "task.approved":
|
|
811
|
+
review = { decided: "approved", by: e.actor, at: e.ts, note: null };
|
|
812
|
+
if (e.payload.merged === true) {
|
|
813
|
+
state = "done";
|
|
814
|
+
merged_at = merged_at ?? e.ts;
|
|
815
|
+
if (e.url) pr_url = e.url;
|
|
816
|
+
}
|
|
817
|
+
break;
|
|
818
|
+
case "task.changes_requested":
|
|
819
|
+
review = { decided: "changes_requested", by: e.actor, at: e.ts, note: payloadStr(e, "note") };
|
|
820
|
+
state = "queued";
|
|
821
|
+
break;
|
|
822
|
+
case "task.merge_failed":
|
|
823
|
+
break;
|
|
824
|
+
case "task.requeued":
|
|
825
|
+
state = "queued";
|
|
826
|
+
lastEnd = e.ts;
|
|
827
|
+
break;
|
|
828
|
+
case "task.held":
|
|
829
|
+
state = "held";
|
|
830
|
+
lastEnd = e.ts;
|
|
831
|
+
break;
|
|
832
|
+
case "task.released":
|
|
833
|
+
state = "queued";
|
|
834
|
+
break;
|
|
835
|
+
default:
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
let preview_state = preview ? preview.state : "none";
|
|
840
|
+
if (preview && preview.state === "ready" && lastPush && lastPush > preview.ts) preview_state = "building";
|
|
841
|
+
if (!branch && lane && lastStart) branch = `relay/${lane}-${task.issue}`;
|
|
842
|
+
const latest2 = [...claims].sort((a, b) => a.claimed_at.localeCompare(b.claimed_at)).at(-1);
|
|
843
|
+
if (latest2) {
|
|
844
|
+
const live = !latest2.released_at && new Date(latest2.lease_until).getTime() > now;
|
|
845
|
+
if (live) {
|
|
846
|
+
if (state === "queued") state = "claimed";
|
|
847
|
+
runner = `runner:${latest2.worker}`;
|
|
848
|
+
} else if (state === "running" && lastStart && latest2.claimed_at <= lastStart && (!lastEnd || lastEnd < lastStart)) {
|
|
849
|
+
state = "queued";
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const own = sorted.filter((e) => !e.kind.startsWith("preview.") || matched.includes(e));
|
|
853
|
+
const last = own.at(-1);
|
|
854
|
+
if (pr != null && !pr_url) pr_url = `https://github.com/${task.repo}/pull/${pr}`;
|
|
855
|
+
return {
|
|
856
|
+
state,
|
|
857
|
+
runner,
|
|
858
|
+
lane,
|
|
859
|
+
pr,
|
|
860
|
+
pr_url,
|
|
861
|
+
issue_url: issueUrl(task.repo, task.issue),
|
|
862
|
+
branch,
|
|
863
|
+
summary: last?.summary ?? null,
|
|
864
|
+
run_summary,
|
|
865
|
+
usage,
|
|
866
|
+
preview_url: preview?.url ?? null,
|
|
867
|
+
preview_state,
|
|
868
|
+
review,
|
|
869
|
+
merged_at,
|
|
870
|
+
updated_at: last?.ts ?? null,
|
|
871
|
+
last_kind: last?.kind ?? null,
|
|
872
|
+
events: own.length
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
async function taskEvents(db, task) {
|
|
876
|
+
const r = await db.query(
|
|
877
|
+
`SELECT ${EVENT_COLS} FROM events
|
|
878
|
+
WHERE (repo = $1 OR (repo IS NULL AND project = $2))
|
|
879
|
+
AND (issue = $3
|
|
880
|
+
OR (kind LIKE 'preview.%' AND issue IS NULL
|
|
881
|
+
AND ((pr IS NOT NULL AND pr IN (SELECT e2.pr FROM events e2 WHERE e2.repo = $1 AND e2.issue = $3 AND e2.pr IS NOT NULL))
|
|
882
|
+
OR coalesce(payload->>'ref', payload->>'branch', '') ~ $4)))
|
|
883
|
+
ORDER BY ts ASC, id ASC LIMIT 2000`,
|
|
884
|
+
[task.repo, task.project, task.issue, relayBranchPattern(task.issue).source]
|
|
885
|
+
);
|
|
886
|
+
return r.map(toEvent);
|
|
887
|
+
}
|
|
888
|
+
async function taskClaims(db, taskId2) {
|
|
889
|
+
const r = await db.query(
|
|
890
|
+
`SELECT w.name AS worker, c.lease_until, c.claimed_at, c.released_at, c.outcome FROM claims c JOIN workers w ON w.id = c.worker_id
|
|
891
|
+
WHERE c.task_id = $1 ORDER BY c.claimed_at ASC LIMIT 200`,
|
|
892
|
+
[taskId2]
|
|
893
|
+
);
|
|
894
|
+
return r.map((x) => ({ worker: x.worker, lease_until: iso(x.lease_until) ?? "", claimed_at: iso(x.claimed_at) ?? "", released_at: iso(x.released_at), outcome: x.outcome ?? null }));
|
|
895
|
+
}
|
|
896
|
+
async function taskState(db, task) {
|
|
897
|
+
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
898
|
+
return deriveTaskStatus(task, events, claims);
|
|
899
|
+
}
|
|
900
|
+
async function taskView(db, task) {
|
|
901
|
+
return { ...task, ...await taskState(db, task) };
|
|
902
|
+
}
|
|
903
|
+
async function listTaskViews(db, opts = {}) {
|
|
904
|
+
const limit = opts.limit ?? 50;
|
|
905
|
+
const params = [];
|
|
906
|
+
const where = [];
|
|
907
|
+
if (opts.project) {
|
|
908
|
+
params.push(opts.project);
|
|
909
|
+
where.push(`project = $${params.length}`);
|
|
910
|
+
}
|
|
911
|
+
if (opts.state) {
|
|
912
|
+
params.push(opts.state);
|
|
913
|
+
where.push(`(state = $${params.length} OR state IS NULL)`);
|
|
914
|
+
}
|
|
915
|
+
params.push(limit);
|
|
916
|
+
const rows = await db.query(`SELECT ${cols}, state FROM tasks ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY created_at DESC, id DESC LIMIT $${params.length}`, params);
|
|
917
|
+
const views = await Promise.all(
|
|
918
|
+
rows.map(async (r) => {
|
|
919
|
+
const task = toTask(r);
|
|
920
|
+
if (r.state == null) await refreshTaskState(db, task.id);
|
|
921
|
+
return taskView(db, task);
|
|
922
|
+
})
|
|
923
|
+
);
|
|
924
|
+
return opts.state ? views.filter((v) => v.state === opts.state) : views;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ../core/src/tasks/station.ts
|
|
928
|
+
var FAILED_CONCLUSIONS2 = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "startup_failure", "action_required"]);
|
|
929
|
+
var conclusionOf = (e) => typeof e.payload?.conclusion === "string" ? e.payload.conclusion : "";
|
|
930
|
+
var finishedOk = (e) => e.kind === "run.finished" && !FAILED_CONCLUSIONS2.has(conclusionOf(e));
|
|
931
|
+
var finishedBad = (e) => e.kind === "run.finished" && FAILED_CONCLUSIONS2.has(conclusionOf(e));
|
|
932
|
+
function latest(events, pick) {
|
|
933
|
+
let best = null;
|
|
934
|
+
for (const e of events) if (pick(e) && (best === null || e.ts > best)) best = e.ts;
|
|
935
|
+
return best;
|
|
936
|
+
}
|
|
937
|
+
var QUEUE_KINDS = /* @__PURE__ */ new Set(["issue.labeled", "issue.opened", "task.created", "pr.closed", "task.changes_requested", "task.requeued", "task.held", "task.released"]);
|
|
938
|
+
var PUSH_KINDS = /* @__PURE__ */ new Set(["pr.opened"]);
|
|
939
|
+
function stationOf(task, events = []) {
|
|
940
|
+
switch (task.state) {
|
|
941
|
+
case "queued":
|
|
942
|
+
case "held":
|
|
943
|
+
return "Queue";
|
|
944
|
+
case "claimed":
|
|
945
|
+
return "Claimed";
|
|
946
|
+
case "running":
|
|
947
|
+
return "Running";
|
|
948
|
+
case "done":
|
|
949
|
+
return "Merged";
|
|
950
|
+
case "failed":
|
|
951
|
+
return "Failed";
|
|
952
|
+
case "review": {
|
|
953
|
+
const push = latest(events, (e) => PUSH_KINDS.has(e.kind) || finishedOk(e));
|
|
954
|
+
const engaged = latest(events, (e) => e.kind === "pr.review" || e.kind === "task.merge_failed");
|
|
955
|
+
if (engaged && (!push || engaged >= push)) return "Review";
|
|
956
|
+
return task.preview_state === "ready" ? "Preview" : "PR";
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function stationSince(task, station, events = [], opts = {}) {
|
|
961
|
+
let ts = null;
|
|
962
|
+
switch (station) {
|
|
963
|
+
case "Queue":
|
|
964
|
+
ts = latest(events, (e) => QUEUE_KINDS.has(e.kind));
|
|
965
|
+
break;
|
|
966
|
+
case "Claimed":
|
|
967
|
+
ts = opts.claimedAt ?? null;
|
|
968
|
+
break;
|
|
969
|
+
case "Running":
|
|
970
|
+
ts = latest(events, (e) => e.kind === "run.started" || e.kind === "run.dispatched");
|
|
971
|
+
break;
|
|
972
|
+
case "PR":
|
|
973
|
+
ts = latest(events, (e) => PUSH_KINDS.has(e.kind) || finishedOk(e));
|
|
974
|
+
break;
|
|
975
|
+
case "Preview":
|
|
976
|
+
ts = latest(events, (e) => e.kind === "preview.ready" && (!task.preview_url || !e.url || e.url === task.preview_url)) ?? latest(events, (e) => e.kind === "preview.ready");
|
|
977
|
+
break;
|
|
978
|
+
case "Review":
|
|
979
|
+
ts = latest(events, (e) => e.kind === "pr.review" || e.kind === "task.merge_failed");
|
|
980
|
+
break;
|
|
981
|
+
case "Merged":
|
|
982
|
+
ts = task.merged_at ?? latest(events, (e) => e.kind === "pr.merged" || e.kind === "issue.closed" || e.kind === "task.approved" && e.payload?.merged === true);
|
|
983
|
+
break;
|
|
984
|
+
case "Failed":
|
|
985
|
+
ts = latest(events, (e) => e.kind === "run.failed" || finishedBad(e));
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
return ts ?? task.updated_at ?? task.created_at ?? new Date(opts.now ?? Date.now()).toISOString();
|
|
989
|
+
}
|
|
990
|
+
function deriveStation(task, events = [], opts = {}) {
|
|
991
|
+
const station = stationOf(task, events);
|
|
992
|
+
return { station, since: stationSince(task, station, events, opts) };
|
|
993
|
+
}
|
|
994
|
+
var FLOW_DONE_MS = 9e4;
|
|
995
|
+
var FLOW_FAILED_MS = 24 * 36e5;
|
|
996
|
+
function failureLine(events) {
|
|
997
|
+
let best;
|
|
998
|
+
for (const e of events) if ((e.kind === "run.failed" || finishedBad(e)) && (!best || e.ts > best.ts)) best = e;
|
|
999
|
+
if (!best) return void 0;
|
|
1000
|
+
const err = typeof best.payload?.error === "string" ? best.payload.error : null;
|
|
1001
|
+
return err ?? best.summary ?? void 0;
|
|
1002
|
+
}
|
|
1003
|
+
function taskTitle(task, events = []) {
|
|
1004
|
+
if (task.title) return task.title;
|
|
1005
|
+
const opened = events.find((e) => e.kind === "issue.opened" && e.summary);
|
|
1006
|
+
if (opened?.summary) return opened.summary;
|
|
1007
|
+
const created = events.find((e) => e.kind === "task.created" && e.summary?.startsWith(`${task.id}: `));
|
|
1008
|
+
if (created?.summary) return created.summary.slice(task.id.length + 2);
|
|
1009
|
+
const pr = events.find((e) => e.kind === "pr.opened" && e.summary);
|
|
1010
|
+
if (pr?.summary) return pr.summary.replace(/\s*\(#\d+\)\s*$/, "");
|
|
1011
|
+
return `#${task.issue}`;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ../core/src/db/task-state.ts
|
|
1015
|
+
function materializeTask(task, events, claims, now = Date.now()) {
|
|
1016
|
+
const status = deriveTaskStatus(task, events, claims, now);
|
|
1017
|
+
const latest2 = [...claims].sort((a, b) => a.claimed_at.localeCompare(b.claimed_at)).at(-1);
|
|
1018
|
+
const claimedAt = latest2 && !latest2.released_at && new Date(latest2.lease_until).getTime() > now ? latest2.claimed_at : null;
|
|
1019
|
+
const { station, since } = deriveStation({ ...status, created_at: task.created_at }, events, { claimedAt, now });
|
|
1020
|
+
const title = taskTitle(task, events);
|
|
1021
|
+
return {
|
|
1022
|
+
state: status.state,
|
|
1023
|
+
station,
|
|
1024
|
+
since,
|
|
1025
|
+
pr: status.pr,
|
|
1026
|
+
pr_url: status.pr_url,
|
|
1027
|
+
preview_state: status.preview_state,
|
|
1028
|
+
preview_url: status.preview_url,
|
|
1029
|
+
runner: status.runner,
|
|
1030
|
+
last_error: status.state === "failed" ? failureLine(events) ?? null : null,
|
|
1031
|
+
cost: status.usage?.cost_equiv ?? null,
|
|
1032
|
+
title: title === `#${task.issue}` ? null : title
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
async function refreshTaskState(db, id, now = Date.now()) {
|
|
1036
|
+
const r = await db.query(`SELECT ${TASK_COLS} FROM tasks WHERE id = $1`, [id]);
|
|
1037
|
+
if (!r[0]) return void 0;
|
|
1038
|
+
const task = toTask(r[0]);
|
|
1039
|
+
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
1040
|
+
const m = materializeTask(task, events, claims, now);
|
|
1041
|
+
await db.query(
|
|
1042
|
+
`UPDATE tasks SET state = $2, station = $3, since = $4::timestamptz, pr = $5, pr_url = $6, preview_state = $7, preview_url = $8, runner = $9,
|
|
1043
|
+
last_error = $10, cost = $11, title = coalesce(title, $12), state_updated_at = $13::timestamptz
|
|
1044
|
+
WHERE id = $1`,
|
|
1045
|
+
[id, m.state, m.station, m.since, m.pr, m.pr_url, m.preview_state, m.preview_url, m.runner, m.last_error, m.cost, m.title, new Date(now).toISOString()]
|
|
1046
|
+
);
|
|
1047
|
+
return m;
|
|
1048
|
+
}
|
|
1049
|
+
var BRANCH_ISSUE = /^relay\/(?:[a-z][a-z0-9-]*-(\d+)|(\d+)-[a-z][a-z0-9-]*)(?:$|[-/])/;
|
|
1050
|
+
async function refreshTaskForEvent(db, e, now = Date.now()) {
|
|
1051
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1052
|
+
if (e.issue != null) {
|
|
1053
|
+
if (e.repo) ids.add(taskId(e.repo, e.issue));
|
|
1054
|
+
else if (e.project) {
|
|
1055
|
+
const r = await db.query("SELECT id FROM tasks WHERE project = $1 AND issue = $2", [e.project, e.issue]);
|
|
1056
|
+
for (const x of r) ids.add(x.id);
|
|
1057
|
+
}
|
|
1058
|
+
} else if (e.kind.startsWith("preview.") && e.repo) {
|
|
1059
|
+
const ref = typeof e.payload.ref === "string" ? e.payload.ref : typeof e.payload.branch === "string" ? e.payload.branch : null;
|
|
1060
|
+
const m = ref ? BRANCH_ISSUE.exec(ref) : null;
|
|
1061
|
+
if (m) ids.add(taskId(e.repo, Number(m[1] ?? m[2])));
|
|
1062
|
+
if (e.pr != null || ref) {
|
|
1063
|
+
const r = await db.query(
|
|
1064
|
+
`SELECT DISTINCT issue FROM events WHERE repo = $1 AND issue IS NOT NULL AND kind NOT LIKE 'preview.%'
|
|
1065
|
+
AND (($2::int IS NOT NULL AND pr = $2::int) OR ($3::text IS NOT NULL AND payload->>'branch' = $3::text))`,
|
|
1066
|
+
[e.repo, e.pr ?? null, ref]
|
|
1067
|
+
);
|
|
1068
|
+
for (const x of r) ids.add(taskId(e.repo, num(x.issue)));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const refreshed = [];
|
|
1072
|
+
for (const id of ids) if (await refreshTaskState(db, id, now)) refreshed.push(id);
|
|
1073
|
+
return refreshed;
|
|
1074
|
+
}
|
|
1075
|
+
async function backfillTaskState(db, opts = {}) {
|
|
1076
|
+
const r = await db.query(`SELECT id FROM tasks ${opts.all ? "" : "WHERE state IS NULL"} ORDER BY created_at ASC, id ASC`);
|
|
1077
|
+
for (const x of r) await refreshTaskState(db, x.id, opts.now);
|
|
1078
|
+
return r.length;
|
|
1079
|
+
}
|
|
1080
|
+
var toFlowRow = (r) => ({
|
|
1081
|
+
...toTask(r),
|
|
1082
|
+
state: r.state,
|
|
1083
|
+
station: r.station,
|
|
1084
|
+
since: iso(r.since) ?? "",
|
|
1085
|
+
pr: r.pr == null ? null : num(r.pr),
|
|
1086
|
+
pr_url: r.pr_url ?? null,
|
|
1087
|
+
preview_state: r.preview_state ?? "none",
|
|
1088
|
+
preview_url: r.preview_url ?? null,
|
|
1089
|
+
runner: r.runner ?? null,
|
|
1090
|
+
last_error: r.last_error ?? null,
|
|
1091
|
+
cost: r.cost == null ? null : num(r.cost),
|
|
1092
|
+
project_repo: r.project_repo ?? null
|
|
1093
|
+
});
|
|
1094
|
+
async function flowTasks(db, opts = {}) {
|
|
1095
|
+
const now = opts.now ?? Date.now();
|
|
1096
|
+
const limit = opts.limit ?? 200;
|
|
1097
|
+
const select = async () => db.query(
|
|
1098
|
+
`SELECT ${TASK_COLS.split(", ").map((c) => `t.${c}`).join(", ")}, t.state, t.station, t.since, t.pr, t.pr_url, t.preview_state, t.preview_url, t.runner, t.last_error, t.cost, p.repo AS project_repo
|
|
1099
|
+
FROM tasks t LEFT JOIN projects p ON p.slug = t.project
|
|
1100
|
+
WHERE t.state IS NULL
|
|
1101
|
+
OR t.state IN ('queued', 'held', 'claimed', 'running', 'review')
|
|
1102
|
+
OR (t.state = 'done' AND t.since > $1::timestamptz)
|
|
1103
|
+
OR (t.state = 'failed' AND t.since > $2::timestamptz)
|
|
1104
|
+
ORDER BY t.created_at DESC, t.id DESC LIMIT $3`,
|
|
1105
|
+
[new Date(now - FLOW_DONE_MS).toISOString(), new Date(now - FLOW_FAILED_MS).toISOString(), limit]
|
|
1106
|
+
);
|
|
1107
|
+
let rows = await select();
|
|
1108
|
+
const empty = rows.filter((r) => r.state == null);
|
|
1109
|
+
if (empty.length) {
|
|
1110
|
+
for (const r of empty) await refreshTaskState(db, r.id, now);
|
|
1111
|
+
rows = await select();
|
|
1112
|
+
}
|
|
1113
|
+
return rows.filter((r) => r.state != null).map(toFlowRow);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
566
1116
|
// ../core/src/db/events.ts
|
|
567
1117
|
var EVENT_COLS = "id, ts, kind, actor, project, repo, issue, pr, lane, model, subscription, tokens_in, tokens_out, cache_read, cache_write, cost_equiv, duration_ms, summary, url, payload, dedupe_key";
|
|
568
|
-
var
|
|
1118
|
+
var cols2 = EVENT_COLS;
|
|
569
1119
|
var toEvent = (r) => ({
|
|
570
1120
|
id: num(r.id),
|
|
571
1121
|
ts: iso(r.ts) ?? "",
|
|
@@ -595,7 +1145,7 @@ async function writeEvent(db, e) {
|
|
|
595
1145
|
`INSERT INTO events (ts, kind, actor, project, repo, issue, pr, lane, model, subscription, tokens_in, tokens_out, cache_read, cache_write, cost_equiv, duration_ms, summary, url, payload, dedupe_key)
|
|
596
1146
|
VALUES (coalesce($1::timestamptz, now()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb, $20)
|
|
597
1147
|
ON CONFLICT (dedupe_key) DO NOTHING
|
|
598
|
-
RETURNING ${
|
|
1148
|
+
RETURNING ${cols2}`,
|
|
599
1149
|
[
|
|
600
1150
|
ts,
|
|
601
1151
|
e.kind,
|
|
@@ -619,7 +1169,10 @@ async function writeEvent(db, e) {
|
|
|
619
1169
|
e.dedupe_key ?? null
|
|
620
1170
|
]
|
|
621
1171
|
);
|
|
622
|
-
|
|
1172
|
+
if (!r[0]) return void 0;
|
|
1173
|
+
const row = toEvent(r[0]);
|
|
1174
|
+
await refreshTaskForEvent(db, row);
|
|
1175
|
+
return row;
|
|
623
1176
|
}
|
|
624
1177
|
async function listEvents(db, opts = {}) {
|
|
625
1178
|
const where = [];
|
|
@@ -636,9 +1189,13 @@ async function listEvents(db, opts = {}) {
|
|
|
636
1189
|
if (opts.before) add("ts < ?::timestamptz", opts.before);
|
|
637
1190
|
if (opts.after) add("ts > ?::timestamptz", opts.after);
|
|
638
1191
|
params.push(opts.limit ?? 100);
|
|
639
|
-
const r = await db.query(`SELECT ${
|
|
1192
|
+
const r = await db.query(`SELECT ${cols2} FROM events ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY ts DESC, id DESC LIMIT $${params.length}`, params);
|
|
640
1193
|
return r.map(toEvent);
|
|
641
1194
|
}
|
|
1195
|
+
async function githubDeliveryStats(db, repoName) {
|
|
1196
|
+
const r = await db.query(`SELECT count(*) AS n, max(ts) AS last FROM events WHERE repo = $1 AND dedupe_key LIKE 'github:%'`, [repoName]);
|
|
1197
|
+
return { count: num(r[0]?.n), last_delivery_at: iso(r[0]?.last) ?? null };
|
|
1198
|
+
}
|
|
642
1199
|
async function usageSummary(db, sinceIso) {
|
|
643
1200
|
const r = await db.query(
|
|
644
1201
|
`SELECT coalesce(subscription, 'unknown') AS subscription, count(*) AS calls, sum(tokens_in) AS tokens_in, sum(tokens_out) AS tokens_out,
|
|
@@ -728,10 +1285,14 @@ function costEquiv(e) {
|
|
|
728
1285
|
// ../core/src/db/repo.ts
|
|
729
1286
|
var repo_exports = {};
|
|
730
1287
|
__export(repo_exports, {
|
|
1288
|
+
ACCESS_CODE_RETENTION_SECONDS: () => ACCESS_CODE_RETENTION_SECONDS,
|
|
731
1289
|
AssetNotFoundError: () => AssetNotFoundError,
|
|
732
1290
|
DEFAULT_RETENTION_DAYS: () => DEFAULT_RETENTION_DAYS,
|
|
733
1291
|
EVENT_COLS: () => EVENT_COLS,
|
|
734
1292
|
LEASE_MS: () => LEASE_MS,
|
|
1293
|
+
MIN_SETTINGS_SECRET_LENGTH: () => MIN_SETTINGS_SECRET_LENGTH,
|
|
1294
|
+
ORPHAN_CLIENT_AGE_SECONDS: () => ORPHAN_CLIENT_AGE_SECONDS,
|
|
1295
|
+
REFRESH_TOKEN_RETENTION_SECONDS: () => REFRESH_TOKEN_RETENTION_SECONDS,
|
|
735
1296
|
TASK_COLS: () => TASK_COLS,
|
|
736
1297
|
TASK_STATES: () => TASK_STATES,
|
|
737
1298
|
TOKEN_PREFIX: () => TOKEN_PREFIX,
|
|
@@ -742,6 +1303,8 @@ __export(repo_exports, {
|
|
|
742
1303
|
WORKER_OFFLINE_MS: () => WORKER_OFFLINE_MS,
|
|
743
1304
|
WORKER_ONLINE_MS: () => WORKER_ONLINE_MS,
|
|
744
1305
|
addDecision: () => addDecision,
|
|
1306
|
+
backfillTaskState: () => backfillTaskState,
|
|
1307
|
+
bumpQueueVersion: () => bumpQueueVersion,
|
|
745
1308
|
claimNext: () => claimNext,
|
|
746
1309
|
clientSecretMatches: () => clientSecretMatches,
|
|
747
1310
|
consumeCode: () => consumeCode,
|
|
@@ -753,24 +1316,29 @@ __export(repo_exports, {
|
|
|
753
1316
|
createToken: () => createToken,
|
|
754
1317
|
createWebhook: () => createWebhook,
|
|
755
1318
|
decideDevice: () => decideDevice,
|
|
1319
|
+
deleteClient: () => deleteClient,
|
|
756
1320
|
deleteSettings: () => deleteSettings,
|
|
757
1321
|
deleteWebhook: () => deleteWebhook,
|
|
758
1322
|
deriveTaskStatus: () => deriveTaskStatus,
|
|
1323
|
+
doorbell: () => doorbell,
|
|
759
1324
|
expireAssets: () => expireAssets,
|
|
760
1325
|
expireLeases: () => expireLeases,
|
|
761
1326
|
findTaskByKey: () => findTaskByKey,
|
|
762
1327
|
findToken: () => findToken,
|
|
763
1328
|
finishDelivery: () => finishDelivery,
|
|
764
1329
|
finishSource: () => finishSource,
|
|
1330
|
+
flowTasks: () => flowTasks,
|
|
765
1331
|
generateWebhookSecret: () => generateWebhookSecret,
|
|
766
1332
|
getAsset: () => getAsset,
|
|
767
1333
|
getClient: () => getClient,
|
|
1334
|
+
getClientSummary: () => getClientSummary,
|
|
768
1335
|
getDeviceByCode: () => getDeviceByCode,
|
|
769
1336
|
getDeviceByUserCode: () => getDeviceByUserCode,
|
|
770
1337
|
getInviteByCode: () => getInviteByCode,
|
|
771
1338
|
getModel: () => getModel,
|
|
772
1339
|
getProject: () => getProject,
|
|
773
1340
|
getProvider: () => getProvider,
|
|
1341
|
+
getQueueVersion: () => getQueueVersion,
|
|
774
1342
|
getRefreshToken: () => getRefreshToken,
|
|
775
1343
|
getSetting: () => getSetting,
|
|
776
1344
|
getSource: () => getSource,
|
|
@@ -779,6 +1347,7 @@ __export(repo_exports, {
|
|
|
779
1347
|
getWebhook: () => getWebhook,
|
|
780
1348
|
getWorker: () => getWorker,
|
|
781
1349
|
getWorkerByName: () => getWorkerByName,
|
|
1350
|
+
githubDeliveryStats: () => githubDeliveryStats,
|
|
782
1351
|
hashToken: () => hashToken,
|
|
783
1352
|
heartbeatWorker: () => heartbeatWorker,
|
|
784
1353
|
insertClient: () => insertClient,
|
|
@@ -786,11 +1355,14 @@ __export(repo_exports, {
|
|
|
786
1355
|
insertDeviceCode: () => insertDeviceCode,
|
|
787
1356
|
insertInvite: () => insertInvite,
|
|
788
1357
|
insertRefreshToken: () => insertRefreshToken,
|
|
1358
|
+
isLoopbackHost: () => isLoopbackHost,
|
|
1359
|
+
isPrivateHost: () => isPrivateHost,
|
|
789
1360
|
isTaskState: () => isTaskState,
|
|
790
1361
|
isTokenScope: () => isTokenScope,
|
|
791
1362
|
issueUrl: () => issueUrl,
|
|
792
1363
|
listAssets: () => listAssets,
|
|
793
1364
|
listClaims: () => listClaims,
|
|
1365
|
+
listClients: () => listClients,
|
|
794
1366
|
listDecisions: () => listDecisions,
|
|
795
1367
|
listDeliveries: () => listDeliveries,
|
|
796
1368
|
listEvents: () => listEvents,
|
|
@@ -801,6 +1373,7 @@ __export(repo_exports, {
|
|
|
801
1373
|
listPendingDevices: () => listPendingDevices,
|
|
802
1374
|
listPreferences: () => listPreferences,
|
|
803
1375
|
listProjects: () => listProjects,
|
|
1376
|
+
listRecentDevices: () => listRecentDevices,
|
|
804
1377
|
listSettingKeys: () => listSettingKeys,
|
|
805
1378
|
listSkills: () => listSkills,
|
|
806
1379
|
listTaskViews: () => listTaskViews,
|
|
@@ -814,16 +1387,23 @@ __export(repo_exports, {
|
|
|
814
1387
|
markOffline: () => markOffline,
|
|
815
1388
|
markOnboarded: () => markOnboarded,
|
|
816
1389
|
matchingWebhooks: () => matchingWebhooks,
|
|
1390
|
+
materializeTask: () => materializeTask,
|
|
817
1391
|
newVersion: () => newVersion,
|
|
818
1392
|
openValue: () => openValue,
|
|
819
1393
|
parseTaskId: () => parseTaskId,
|
|
1394
|
+
previewMatches: () => previewMatches,
|
|
820
1395
|
projectStats: () => projectStats,
|
|
821
1396
|
pruneGuidance: () => pruneGuidance,
|
|
822
1397
|
publicWebhook: () => publicWebhook,
|
|
823
1398
|
publishAsset: () => publishAsset,
|
|
1399
|
+
purgeExpiredAccess: () => purgeExpiredAccess,
|
|
824
1400
|
putSetting: () => putSetting,
|
|
1401
|
+
queuedCount: () => queuedCount,
|
|
825
1402
|
redeemDevice: () => redeemDevice,
|
|
1403
|
+
refreshTaskForEvent: () => refreshTaskForEvent,
|
|
1404
|
+
refreshTaskState: () => refreshTaskState,
|
|
826
1405
|
registerWorker: () => registerWorker,
|
|
1406
|
+
relayBranchPattern: () => relayBranchPattern,
|
|
827
1407
|
releaseClaim: () => releaseClaim,
|
|
828
1408
|
removeMissingFileDigests: () => removeMissingFileDigests,
|
|
829
1409
|
replaceGuidance: () => replaceGuidance,
|
|
@@ -1233,7 +1813,7 @@ function retentionDays() {
|
|
|
1233
1813
|
const n = Number(process.env.RELAY_ASSET_RETENTION_DAYS);
|
|
1234
1814
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS;
|
|
1235
1815
|
}
|
|
1236
|
-
var
|
|
1816
|
+
var cols3 = "id, project, issue, pr, title, description, kind, mime, size, sha256, storage_key, thumb_key, width, height, duration_ms, version, parent_id, status, created_by, created_at, reviewed_by, reviewed_at, review_note, public_url, expires_at, tags";
|
|
1237
1817
|
var str = (v) => v === null || v === void 0 ? null : String(v);
|
|
1238
1818
|
var int = (v) => v === null || v === void 0 ? null : num(v);
|
|
1239
1819
|
var toAsset = (r) => ({
|
|
@@ -1282,7 +1862,7 @@ async function insertAsset(db, a, version, parentId) {
|
|
|
1282
1862
|
const r = await db.query(
|
|
1283
1863
|
`INSERT INTO assets (id, project, issue, pr, title, description, kind, mime, size, sha256, storage_key, thumb_key, width, height, duration_ms, version, parent_id, created_by, tags)
|
|
1284
1864
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::text[])
|
|
1285
|
-
RETURNING ${
|
|
1865
|
+
RETURNING ${cols3}`,
|
|
1286
1866
|
[
|
|
1287
1867
|
id,
|
|
1288
1868
|
a.project ?? null,
|
|
@@ -1321,7 +1901,7 @@ async function createAsset(db, a) {
|
|
|
1321
1901
|
return db.transaction((tx) => insertAsset(tx, a, 1, null));
|
|
1322
1902
|
}
|
|
1323
1903
|
async function getAsset(db, id) {
|
|
1324
|
-
const r = await db.query(`SELECT ${
|
|
1904
|
+
const r = await db.query(`SELECT ${cols3} FROM assets WHERE id = $1`, [id]);
|
|
1325
1905
|
return r[0] ? toAsset(r[0]) : void 0;
|
|
1326
1906
|
}
|
|
1327
1907
|
async function listAssets(db, opts = {}) {
|
|
@@ -1338,7 +1918,7 @@ async function listAssets(db, opts = {}) {
|
|
|
1338
1918
|
if (opts.pr !== void 0) add("pr = ?", opts.pr);
|
|
1339
1919
|
params.push(opts.limit ?? 100);
|
|
1340
1920
|
const r = await db.query(
|
|
1341
|
-
`SELECT ${
|
|
1921
|
+
`SELECT ${cols3} FROM assets ${where.length ? "WHERE " + where.join(" AND ") : ""}
|
|
1342
1922
|
ORDER BY (status = 'pending') DESC, created_at DESC, id DESC LIMIT $${params.length}`,
|
|
1343
1923
|
params
|
|
1344
1924
|
);
|
|
@@ -1354,7 +1934,7 @@ async function listVersions(db, id) {
|
|
|
1354
1934
|
SELECT a.* FROM assets a WHERE a.id = (SELECT id FROM root)
|
|
1355
1935
|
UNION ALL SELECT c.* FROM assets c JOIN down ON c.parent_id = down.id
|
|
1356
1936
|
)
|
|
1357
|
-
SELECT ${
|
|
1937
|
+
SELECT ${cols3} FROM down ORDER BY version, created_at`,
|
|
1358
1938
|
[id]
|
|
1359
1939
|
);
|
|
1360
1940
|
return r.map(toAsset);
|
|
@@ -1389,7 +1969,7 @@ async function reviewAsset(db, id, r) {
|
|
|
1389
1969
|
const rows = await tx.query(
|
|
1390
1970
|
`UPDATE assets SET status = $2, reviewed_by = $3, reviewed_at = now(), review_note = $4,
|
|
1391
1971
|
expires_at = CASE WHEN $2 = 'rejected' THEN now() + ($5 || ' days')::interval ELSE NULL END
|
|
1392
|
-
WHERE id = $1 RETURNING ${
|
|
1972
|
+
WHERE id = $1 RETURNING ${cols3}`,
|
|
1393
1973
|
[id, r.decision, r.by, r.note ?? null, String(retentionDays())]
|
|
1394
1974
|
);
|
|
1395
1975
|
if (!rows[0]) throw new AssetNotFoundError(id);
|
|
@@ -1412,7 +1992,7 @@ async function reviewAsset(db, id, r) {
|
|
|
1412
1992
|
}
|
|
1413
1993
|
async function publishAsset(db, id, publicUrl, opts = {}) {
|
|
1414
1994
|
return db.transaction(async (tx) => {
|
|
1415
|
-
const rows = await tx.query(`UPDATE assets SET status = 'published', public_url = $2, expires_at = NULL WHERE id = $1 RETURNING ${
|
|
1995
|
+
const rows = await tx.query(`UPDATE assets SET status = 'published', public_url = $2, expires_at = NULL WHERE id = $1 RETURNING ${cols3}`, [id, publicUrl]);
|
|
1416
1996
|
if (!rows[0]) throw new AssetNotFoundError(id);
|
|
1417
1997
|
const row = toAsset(rows[0]);
|
|
1418
1998
|
await writeEvent(tx, {
|
|
@@ -1430,7 +2010,7 @@ async function publishAsset(db, id, publicUrl, opts = {}) {
|
|
|
1430
2010
|
}
|
|
1431
2011
|
async function listExpiredAssets(db, now = /* @__PURE__ */ new Date()) {
|
|
1432
2012
|
const r = await db.query(
|
|
1433
|
-
`SELECT ${
|
|
2013
|
+
`SELECT ${cols3} FROM assets WHERE status IN ('rejected', 'superseded') AND expires_at IS NOT NULL AND expires_at <= $1::timestamptz ORDER BY expires_at`,
|
|
1434
2014
|
[now.toISOString()]
|
|
1435
2015
|
);
|
|
1436
2016
|
return r.map(toAsset);
|
|
@@ -1486,7 +2066,7 @@ var TOKEN_SCOPE_DESCRIPTIONS = {
|
|
|
1486
2066
|
"tokens:write": "Mint, list and revoke tokens (relay token in site mode).",
|
|
1487
2067
|
"work:claim": "Register a worker, heartbeat, claim queued tasks and report runs (relay work); pair with memory:read for the brief."
|
|
1488
2068
|
};
|
|
1489
|
-
var
|
|
2069
|
+
var cols4 = "id, name, scopes, created_by, created_at, last_used_at, revoked_at, expires_at, client_id";
|
|
1490
2070
|
var toToken = (r) => ({
|
|
1491
2071
|
id: r.id,
|
|
1492
2072
|
name: r.name,
|
|
@@ -1508,14 +2088,14 @@ async function createToken(db, input) {
|
|
|
1508
2088
|
if (!input.name.trim()) throw new Error("a token needs a name");
|
|
1509
2089
|
const token = TOKEN_PREFIX + randomBytes2(32).toString("base64url");
|
|
1510
2090
|
const r = await db.query(
|
|
1511
|
-
`INSERT INTO tokens (id, name, token_hash, scopes, created_by, expires_at, client_id) VALUES ($1, $2, $3, $4::text[], $5, $6, $7) RETURNING ${
|
|
2091
|
+
`INSERT INTO tokens (id, name, token_hash, scopes, created_by, expires_at, client_id) VALUES ($1, $2, $3, $4::text[], $5, $6, $7) RETURNING ${cols4}`,
|
|
1512
2092
|
[ulid(), input.name.trim(), hashToken(token), scopes, input.createdBy, input.expiresAt ? new Date(input.expiresAt) : null, input.clientId ?? null]
|
|
1513
2093
|
);
|
|
1514
2094
|
return { token, row: toToken(r[0]) };
|
|
1515
2095
|
}
|
|
1516
2096
|
async function findToken(db, token) {
|
|
1517
2097
|
if (!token) return void 0;
|
|
1518
|
-
const r = await db.query(`SELECT ${
|
|
2098
|
+
const r = await db.query(`SELECT ${cols4} FROM tokens WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now())`, [hashToken(token)]);
|
|
1519
2099
|
if (!r[0]) return void 0;
|
|
1520
2100
|
const row = toToken(r[0]);
|
|
1521
2101
|
await db.query("UPDATE tokens SET last_used_at = now() WHERE id = $1", [row.id]).catch(() => {
|
|
@@ -1523,198 +2103,35 @@ async function findToken(db, token) {
|
|
|
1523
2103
|
return row;
|
|
1524
2104
|
}
|
|
1525
2105
|
async function listTokens(db, opts = {}) {
|
|
1526
|
-
const r = await db.query(`SELECT ${
|
|
2106
|
+
const r = await db.query(`SELECT ${cols4} FROM tokens ${opts.includeRevoked ? "" : "WHERE revoked_at IS NULL"} ORDER BY created_at DESC`);
|
|
1527
2107
|
return r.map(toToken);
|
|
1528
2108
|
}
|
|
1529
2109
|
async function getTokenById(db, id) {
|
|
1530
|
-
const r = await db.query(`SELECT ${
|
|
2110
|
+
const r = await db.query(`SELECT ${cols4} FROM tokens WHERE id = $1`, [id]);
|
|
1531
2111
|
return r[0] ? toToken(r[0]) : void 0;
|
|
1532
2112
|
}
|
|
1533
2113
|
async function revokeTokenById(db, id) {
|
|
1534
|
-
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND id = $1 RETURNING ${
|
|
2114
|
+
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND id = $1 RETURNING ${cols4}`, [id]);
|
|
1535
2115
|
return r[0] ? toToken(r[0]) : void 0;
|
|
1536
2116
|
}
|
|
1537
2117
|
async function revokeTokenByHash(db, tokenHash) {
|
|
1538
|
-
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND token_hash = $1 RETURNING ${
|
|
2118
|
+
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND token_hash = $1 RETURNING ${cols4}`, [tokenHash]);
|
|
1539
2119
|
return r[0] ? toToken(r[0]) : void 0;
|
|
1540
2120
|
}
|
|
1541
2121
|
async function revokeToken(db, idOrName) {
|
|
1542
|
-
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND (id = $1 OR name = $1) RETURNING ${
|
|
2122
|
+
const r = await db.query(`UPDATE tokens SET revoked_at = now() WHERE revoked_at IS NULL AND (id = $1 OR name = $1) RETURNING ${cols4}`, [idOrName]);
|
|
1543
2123
|
return r[0] ? toToken(r[0]) : void 0;
|
|
1544
2124
|
}
|
|
1545
2125
|
|
|
1546
|
-
// ../core/src/db/
|
|
1547
|
-
|
|
1548
|
-
var
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
var cols4 = "id, project, repo, issue, idempotency_key, created_by, created_at, lane";
|
|
1556
|
-
var toTask = (r) => ({
|
|
1557
|
-
id: r.id,
|
|
1558
|
-
project: r.project,
|
|
1559
|
-
repo: r.repo,
|
|
1560
|
-
issue: num(r.issue),
|
|
1561
|
-
idempotency_key: r.idempotency_key ?? null,
|
|
1562
|
-
created_by: r.created_by,
|
|
1563
|
-
created_at: iso(r.created_at) ?? "",
|
|
1564
|
-
lane: r.lane ?? null
|
|
1565
|
-
});
|
|
1566
|
-
var TASK_COLS = cols4;
|
|
1567
|
-
async function findTaskByKey(db, key) {
|
|
1568
|
-
const r = await db.query(`SELECT ${cols4} FROM tasks WHERE idempotency_key = $1`, [key]);
|
|
1569
|
-
return r[0] ? toTask(r[0]) : void 0;
|
|
1570
|
-
}
|
|
1571
|
-
async function getTask(db, id) {
|
|
1572
|
-
const r = await db.query(`SELECT ${cols4} FROM tasks WHERE id = $1`, [id]);
|
|
1573
|
-
return r[0] ? toTask(r[0]) : void 0;
|
|
1574
|
-
}
|
|
1575
|
-
async function createTask(db, t) {
|
|
1576
|
-
const id = taskId(t.repo, t.issue);
|
|
1577
|
-
const key = t.idempotencyKey?.trim() || null;
|
|
1578
|
-
if (key) {
|
|
1579
|
-
const existing2 = await findTaskByKey(db, key);
|
|
1580
|
-
if (existing2) return { task: existing2, created: false };
|
|
1581
|
-
}
|
|
1582
|
-
const r = await db.query(
|
|
1583
|
-
`INSERT INTO tasks (id, project, repo, issue, idempotency_key, created_by, lane) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
1584
|
-
ON CONFLICT (id) DO NOTHING RETURNING ${cols4}`,
|
|
1585
|
-
[id, t.project, t.repo, t.issue, key, t.createdBy, t.lane ?? null]
|
|
1586
|
-
);
|
|
1587
|
-
if (r[0]) return { task: toTask(r[0]), created: true };
|
|
1588
|
-
const existing = await getTask(db, id);
|
|
1589
|
-
if (!existing) throw new Error(`task ${id} could not be created`);
|
|
1590
|
-
if (t.lane && existing.lane !== t.lane) {
|
|
1591
|
-
await db.query("UPDATE tasks SET lane = $2 WHERE id = $1", [id, t.lane]);
|
|
1592
|
-
existing.lane = t.lane;
|
|
1593
|
-
}
|
|
1594
|
-
return { task: existing, created: false };
|
|
1595
|
-
}
|
|
1596
|
-
async function listTasks(db, opts = {}) {
|
|
1597
|
-
const params = [];
|
|
1598
|
-
const where = opts.project ? (params.push(opts.project), `WHERE project = $1`) : "";
|
|
1599
|
-
params.push(opts.limit ?? 50);
|
|
1600
|
-
const r = await db.query(`SELECT ${cols4} FROM tasks ${where} ORDER BY created_at DESC, id DESC LIMIT $${params.length}`, params);
|
|
1601
|
-
return r.map(toTask);
|
|
1602
|
-
}
|
|
1603
|
-
var FAILED_CONCLUSIONS = /* @__PURE__ */ new Set(["failure", "cancelled", "timed_out", "startup_failure", "action_required"]);
|
|
1604
|
-
function deriveTaskStatus(task, events, claims = [], now = Date.now()) {
|
|
1605
|
-
const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts) || a.id - b.id);
|
|
1606
|
-
let state = "queued";
|
|
1607
|
-
let runner = null;
|
|
1608
|
-
let lane = null;
|
|
1609
|
-
let pr = null;
|
|
1610
|
-
let pr_url = null;
|
|
1611
|
-
let lastStart = null;
|
|
1612
|
-
let lastEnd = null;
|
|
1613
|
-
for (const e of sorted) {
|
|
1614
|
-
if (e.lane) lane = e.lane;
|
|
1615
|
-
if (e.pr != null) pr = e.pr;
|
|
1616
|
-
switch (e.kind) {
|
|
1617
|
-
case "issue.labeled":
|
|
1618
|
-
state = "queued";
|
|
1619
|
-
break;
|
|
1620
|
-
case "run.started":
|
|
1621
|
-
state = "running";
|
|
1622
|
-
lastStart = e.ts;
|
|
1623
|
-
if (e.actor.startsWith("runner:")) runner = e.actor;
|
|
1624
|
-
break;
|
|
1625
|
-
case "run.dispatched":
|
|
1626
|
-
state = "running";
|
|
1627
|
-
break;
|
|
1628
|
-
case "run.failed":
|
|
1629
|
-
state = "failed";
|
|
1630
|
-
lastEnd = e.ts;
|
|
1631
|
-
break;
|
|
1632
|
-
case "run.finished": {
|
|
1633
|
-
const conclusion = typeof e.payload.conclusion === "string" ? e.payload.conclusion : "";
|
|
1634
|
-
state = FAILED_CONCLUSIONS.has(conclusion) ? "failed" : "review";
|
|
1635
|
-
lastEnd = e.ts;
|
|
1636
|
-
if (e.actor.startsWith("runner:")) runner = e.actor;
|
|
1637
|
-
break;
|
|
1638
|
-
}
|
|
1639
|
-
case "pr.opened":
|
|
1640
|
-
state = "review";
|
|
1641
|
-
if (e.url) pr_url = e.url;
|
|
1642
|
-
break;
|
|
1643
|
-
case "pr.merged":
|
|
1644
|
-
case "issue.closed":
|
|
1645
|
-
state = "done";
|
|
1646
|
-
if (e.kind === "pr.merged" && e.url) pr_url = e.url;
|
|
1647
|
-
break;
|
|
1648
|
-
case "pr.closed":
|
|
1649
|
-
state = "queued";
|
|
1650
|
-
break;
|
|
1651
|
-
default:
|
|
1652
|
-
break;
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
const latest = [...claims].sort((a, b) => a.claimed_at.localeCompare(b.claimed_at)).at(-1);
|
|
1656
|
-
if (latest) {
|
|
1657
|
-
const live = !latest.released_at && new Date(latest.lease_until).getTime() > now;
|
|
1658
|
-
if (live) {
|
|
1659
|
-
if (state === "queued") state = "claimed";
|
|
1660
|
-
runner = `runner:${latest.worker}`;
|
|
1661
|
-
} else if (state === "running" && lastStart && latest.claimed_at <= lastStart && (!lastEnd || lastEnd < lastStart)) {
|
|
1662
|
-
state = "queued";
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
const last = sorted.at(-1);
|
|
1666
|
-
if (pr != null && !pr_url) pr_url = `https://github.com/${task.repo}/pull/${pr}`;
|
|
1667
|
-
return {
|
|
1668
|
-
state,
|
|
1669
|
-
runner,
|
|
1670
|
-
lane,
|
|
1671
|
-
pr,
|
|
1672
|
-
pr_url,
|
|
1673
|
-
issue_url: issueUrl(task.repo, task.issue),
|
|
1674
|
-
summary: last?.summary ?? null,
|
|
1675
|
-
updated_at: last?.ts ?? null,
|
|
1676
|
-
last_kind: last?.kind ?? null,
|
|
1677
|
-
events: sorted.length
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
async function taskEvents(db, task) {
|
|
1681
|
-
const r = await db.query(
|
|
1682
|
-
`SELECT ${EVENT_COLS} FROM events WHERE issue = $3 AND (repo = $1 OR (repo IS NULL AND project = $2)) ORDER BY ts ASC, id ASC LIMIT 2000`,
|
|
1683
|
-
[task.repo, task.project, task.issue]
|
|
1684
|
-
);
|
|
1685
|
-
return r.map(toEvent);
|
|
1686
|
-
}
|
|
1687
|
-
async function taskClaims(db, taskId2) {
|
|
1688
|
-
const r = await db.query(
|
|
1689
|
-
`SELECT w.name AS worker, c.lease_until, c.claimed_at, c.released_at, c.outcome FROM claims c JOIN workers w ON w.id = c.worker_id
|
|
1690
|
-
WHERE c.task_id = $1 ORDER BY c.claimed_at ASC LIMIT 200`,
|
|
1691
|
-
[taskId2]
|
|
1692
|
-
);
|
|
1693
|
-
return r.map((x) => ({ worker: x.worker, lease_until: iso(x.lease_until) ?? "", claimed_at: iso(x.claimed_at) ?? "", released_at: iso(x.released_at), outcome: x.outcome ?? null }));
|
|
1694
|
-
}
|
|
1695
|
-
async function taskState(db, task) {
|
|
1696
|
-
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
1697
|
-
return deriveTaskStatus(task, events, claims);
|
|
1698
|
-
}
|
|
1699
|
-
async function taskView(db, task) {
|
|
1700
|
-
return { ...task, ...await taskState(db, task) };
|
|
1701
|
-
}
|
|
1702
|
-
async function listTaskViews(db, opts = {}) {
|
|
1703
|
-
const rows = await listTasks(db, { ...opts.project ? { project: opts.project } : {}, limit: opts.state ? Math.max((opts.limit ?? 50) * 4, 200) : opts.limit ?? 50 });
|
|
1704
|
-
const views = await Promise.all(rows.map((t) => taskView(db, t)));
|
|
1705
|
-
const filtered = opts.state ? views.filter((v) => v.state === opts.state) : views;
|
|
1706
|
-
return filtered.slice(0, opts.limit ?? 50);
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
// ../core/src/db/webhooks.ts
|
|
1710
|
-
import { randomBytes as randomBytes3 } from "crypto";
|
|
1711
|
-
var publicWebhook = (w) => {
|
|
1712
|
-
const { secret: _s, bearer, ...rest } = w;
|
|
1713
|
-
return { ...rest, has_bearer: !!bearer };
|
|
1714
|
-
};
|
|
1715
|
-
var WEBHOOK_EVENT_KINDS = ["run.finished", "run.failed", "pr.review", "pr.merged", "asset.uploaded", "asset.reviewed", "task.created", "issue.opened", "pr.opened", "preview.ready", "preview.failed", "decision.captured"];
|
|
1716
|
-
var cols5 = "id, project, url, secret, bearer, events, active, created_by, created_at";
|
|
1717
|
-
var toHook = (r) => ({
|
|
2126
|
+
// ../core/src/db/webhooks.ts
|
|
2127
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
2128
|
+
var publicWebhook = (w) => {
|
|
2129
|
+
const { secret: _s, bearer, ...rest } = w;
|
|
2130
|
+
return { ...rest, has_bearer: !!bearer };
|
|
2131
|
+
};
|
|
2132
|
+
var WEBHOOK_EVENT_KINDS = ["run.finished", "run.failed", "pr.review", "pr.merged", "asset.uploaded", "asset.reviewed", "task.created", "issue.opened", "pr.opened", "preview.ready", "preview.failed", "decision.captured"];
|
|
2133
|
+
var cols5 = "id, project, url, secret, bearer, events, active, created_by, created_at";
|
|
2134
|
+
var toHook = (r) => ({
|
|
1718
2135
|
id: r.id,
|
|
1719
2136
|
project: r.project ?? null,
|
|
1720
2137
|
url: r.url,
|
|
@@ -1726,14 +2143,48 @@ var toHook = (r) => ({
|
|
|
1726
2143
|
created_at: iso(r.created_at) ?? ""
|
|
1727
2144
|
});
|
|
1728
2145
|
var generateWebhookSecret = () => "whsec_" + randomBytes3(32).toString("base64url");
|
|
1729
|
-
|
|
2146
|
+
var ipv4 = (host) => {
|
|
2147
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
2148
|
+
if (!m) return void 0;
|
|
2149
|
+
const parts = m.slice(1).map(Number);
|
|
2150
|
+
return parts.every((n) => n <= 255) ? parts : void 0;
|
|
2151
|
+
};
|
|
2152
|
+
var bareHost = (hostname) => {
|
|
2153
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
2154
|
+
const mapped = /^::ffff:(?:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|([0-9a-f]{1,4}):([0-9a-f]{1,4}))$/.exec(host);
|
|
2155
|
+
if (!mapped) return host;
|
|
2156
|
+
if (mapped[1]) return mapped[1];
|
|
2157
|
+
const hi = parseInt(mapped[2], 16);
|
|
2158
|
+
const lo = parseInt(mapped[3], 16);
|
|
2159
|
+
return `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
2160
|
+
};
|
|
2161
|
+
function isLoopbackHost(hostname) {
|
|
2162
|
+
const host = bareHost(hostname);
|
|
2163
|
+
if (host === "localhost" || host.endsWith(".localhost") || host === "::1" || host === "0.0.0.0" || host === "::") return true;
|
|
2164
|
+
const v4 = ipv4(host);
|
|
2165
|
+
return !!v4 && v4[0] === 127;
|
|
2166
|
+
}
|
|
2167
|
+
function isPrivateHost(hostname) {
|
|
2168
|
+
const host = bareHost(hostname);
|
|
2169
|
+
if (host.endsWith(".internal") || host.endsWith(".local")) return true;
|
|
2170
|
+
if (/^fe[89ab][0-9a-f]:/.test(host) || /^f[cd][0-9a-f]{2}:/.test(host)) return true;
|
|
2171
|
+
const v4 = ipv4(host);
|
|
2172
|
+
if (!v4) return false;
|
|
2173
|
+
const [a, b] = v4;
|
|
2174
|
+
return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
|
|
2175
|
+
}
|
|
2176
|
+
function validateWebhookUrl(url, nodeEnv = process.env.NODE_ENV) {
|
|
1730
2177
|
let u;
|
|
1731
2178
|
try {
|
|
1732
2179
|
u = new URL(url);
|
|
1733
2180
|
} catch {
|
|
1734
|
-
return "url must be an absolute
|
|
2181
|
+
return "url must be an absolute https URL";
|
|
1735
2182
|
}
|
|
1736
|
-
if (u.protocol !== "https:" && u.protocol !== "http:") return "url must use
|
|
2183
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return "url must use https";
|
|
2184
|
+
const production = nodeEnv === "production";
|
|
2185
|
+
if (isLoopbackHost(u.hostname)) return production ? "url must not point at this machine (loopback)" : void 0;
|
|
2186
|
+
if (isPrivateHost(u.hostname)) return "url must not point at a link-local, private or internal network address";
|
|
2187
|
+
if (u.protocol !== "https:") return "url must use https (http is allowed for localhost outside production only)";
|
|
1737
2188
|
return void 0;
|
|
1738
2189
|
}
|
|
1739
2190
|
async function createWebhook(db, w) {
|
|
@@ -1802,10 +2253,28 @@ import { createHash as createHash2, randomBytes as randomBytes4, timingSafeEqual
|
|
|
1802
2253
|
var b64url = (buf) => buf.toString("base64url");
|
|
1803
2254
|
var opaqueSecret = (prefix, bytes = 32) => prefix + b64url(randomBytes4(bytes));
|
|
1804
2255
|
var sha256hex = (s) => createHash2("sha256").update(s).digest("hex");
|
|
2256
|
+
var USER_CODE_PREFIX = "RLAY";
|
|
2257
|
+
function normalizeUserCode(input) {
|
|
2258
|
+
if (!input) return void 0;
|
|
2259
|
+
let s = input.toUpperCase().replace(/[\s-]+/g, "");
|
|
2260
|
+
if (s.startsWith(USER_CODE_PREFIX)) s = s.slice(USER_CODE_PREFIX.length);
|
|
2261
|
+
if (!s || !/^[A-Z0-9]{4,8}$/.test(s)) return void 0;
|
|
2262
|
+
return `${USER_CODE_PREFIX}-${s}`;
|
|
2263
|
+
}
|
|
1805
2264
|
function parseScopeParam(raw) {
|
|
1806
2265
|
const list = Array.isArray(raw) ? raw : (raw ?? "").split(/[\s,]+/);
|
|
1807
2266
|
return [...new Set(list.map((s) => s.trim()).filter(Boolean))];
|
|
1808
2267
|
}
|
|
2268
|
+
var scopeString = (scopes) => scopes.join(" ");
|
|
2269
|
+
var UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
|
2270
|
+
function parseDuration(raw) {
|
|
2271
|
+
if (raw === null || raw === void 0 || raw === "") return void 0;
|
|
2272
|
+
if (typeof raw === "number") return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : void 0;
|
|
2273
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([smhdw])?\s*$/i.exec(raw);
|
|
2274
|
+
if (!m) return void 0;
|
|
2275
|
+
const n = Number(m[1]) * UNITS[(m[2] ?? "s").toLowerCase()];
|
|
2276
|
+
return n > 0 ? Math.floor(n) : void 0;
|
|
2277
|
+
}
|
|
1809
2278
|
|
|
1810
2279
|
// ../core/src/db/oauth.ts
|
|
1811
2280
|
var ccols = "client_id, client_secret_hash, name, redirect_uris, grant_types, token_endpoint_auth_method, scopes, software_id, client_uri, created_by, created_at";
|
|
@@ -1834,6 +2303,29 @@ async function getClient(db, clientId) {
|
|
|
1834
2303
|
const r = await db.query(`SELECT ${ccols} FROM oauth_clients WHERE client_id = $1`, [clientId]);
|
|
1835
2304
|
return r[0] ? toClient(r[0]) : void 0;
|
|
1836
2305
|
}
|
|
2306
|
+
var toClientSummary = (r) => ({ ...toClient(r), live_tokens: num(r.live_tokens), pending_codes: num(r.pending_codes), live_refresh_tokens: num(r.live_refresh_tokens) });
|
|
2307
|
+
var scols = `${ccols.split(", ").map((c) => `c.${c}`).join(", ")},
|
|
2308
|
+
(SELECT count(*) FROM tokens t WHERE t.client_id = c.client_id AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at > $1)) AS live_tokens,
|
|
2309
|
+
(SELECT count(*) FROM oauth_device_codes d WHERE d.client_id = c.client_id AND d.status IN ('pending', 'approved') AND d.expires_at > $1) AS pending_codes,
|
|
2310
|
+
(SELECT count(*) FROM oauth_refresh_tokens r WHERE r.client_id = c.client_id AND r.used_at IS NULL AND r.revoked_at IS NULL AND r.expires_at > $1) AS live_refresh_tokens`;
|
|
2311
|
+
async function listClients(db, now = /* @__PURE__ */ new Date()) {
|
|
2312
|
+
const r = await db.query(`SELECT ${scols} FROM oauth_clients c ORDER BY c.created_at DESC`, [now]);
|
|
2313
|
+
return r.map(toClientSummary);
|
|
2314
|
+
}
|
|
2315
|
+
async function getClientSummary(db, clientId, now = /* @__PURE__ */ new Date()) {
|
|
2316
|
+
const r = await db.query(`SELECT ${scols} FROM oauth_clients c WHERE c.client_id = $2`, [now, clientId]);
|
|
2317
|
+
return r[0] ? toClientSummary(r[0]) : void 0;
|
|
2318
|
+
}
|
|
2319
|
+
async function deleteClient(db, clientId, now = /* @__PURE__ */ new Date()) {
|
|
2320
|
+
const c = await getClientSummary(db, clientId, now);
|
|
2321
|
+
if (!c) return void 0;
|
|
2322
|
+
if (c.live_tokens || c.pending_codes || c.live_refresh_tokens) {
|
|
2323
|
+
throw new Error(`client ${c.name} still has ${c.live_tokens} live token(s), ${c.pending_codes} pending code(s) and ${c.live_refresh_tokens} live refresh token(s); revoke them first`);
|
|
2324
|
+
}
|
|
2325
|
+
await db.query("DELETE FROM oauth_refresh_tokens WHERE client_id = $1", [clientId]);
|
|
2326
|
+
await db.query("DELETE FROM oauth_clients WHERE client_id = $1", [clientId]);
|
|
2327
|
+
return c;
|
|
2328
|
+
}
|
|
1837
2329
|
async function clientSecretMatches(db, clientId, secret) {
|
|
1838
2330
|
if (!secret) return false;
|
|
1839
2331
|
const r = await db.query("SELECT client_secret_hash FROM oauth_clients WHERE client_id = $1", [clientId]);
|
|
@@ -1907,6 +2399,16 @@ async function listPendingDevices(db, now = /* @__PURE__ */ new Date()) {
|
|
|
1907
2399
|
const r = await db.query(`SELECT ${dcols2} ${dfrom} WHERE d.status = 'pending' AND d.expires_at > $1 ORDER BY d.created_at DESC`, [now]);
|
|
1908
2400
|
return r.map(toDevice);
|
|
1909
2401
|
}
|
|
2402
|
+
async function listRecentDevices(db, opts) {
|
|
2403
|
+
const now = opts.now ?? /* @__PURE__ */ new Date();
|
|
2404
|
+
const r = await db.query(
|
|
2405
|
+
`SELECT ${dcols2}, t.name AS token_name ${dfrom} LEFT JOIN tokens t ON t.id = d.token_id
|
|
2406
|
+
WHERE d.created_at >= $1 OR (d.status IN ('pending', 'approved') AND d.expires_at > $2)
|
|
2407
|
+
ORDER BY d.created_at DESC`,
|
|
2408
|
+
[opts.since, now]
|
|
2409
|
+
);
|
|
2410
|
+
return r.map((row) => ({ ...toDevice(row), token_name: row.token_name ?? null }));
|
|
2411
|
+
}
|
|
1910
2412
|
async function touchDevicePoll(db, deviceCodeHash, now) {
|
|
1911
2413
|
await db.query("UPDATE oauth_device_codes SET last_polled_at = $2 WHERE device_code_hash = $1", [deviceCodeHash, now]);
|
|
1912
2414
|
}
|
|
@@ -1916,8 +2418,8 @@ async function decideDevice(db, userCode, decision) {
|
|
|
1916
2418
|
if (!r[0]) return void 0;
|
|
1917
2419
|
return getDeviceByUserCode(db, userCode);
|
|
1918
2420
|
}
|
|
1919
|
-
async function redeemDevice(db, deviceCodeHash, tokenId) {
|
|
1920
|
-
const r = await db.query(`UPDATE oauth_device_codes SET status = 'redeemed', token_id = $2 WHERE device_code_hash = $1 AND status = 'approved' RETURNING device_code_hash`, [deviceCodeHash, tokenId]);
|
|
2421
|
+
async function redeemDevice(db, deviceCodeHash, tokenId, now = /* @__PURE__ */ new Date()) {
|
|
2422
|
+
const r = await db.query(`UPDATE oauth_device_codes SET status = 'redeemed', token_id = $2 WHERE device_code_hash = $1 AND status = 'approved' AND expires_at > $3 RETURNING device_code_hash`, [deviceCodeHash, tokenId, now]);
|
|
1921
2423
|
return r.length > 0;
|
|
1922
2424
|
}
|
|
1923
2425
|
var rcols = "token_hash, client_id, access_token_id, scopes, actor, expires_at, rotated_from, used_at, revoked_at, created_at";
|
|
@@ -2008,12 +2510,38 @@ async function revokeInvite(db, idOrName) {
|
|
|
2008
2510
|
const r = await db.query(`UPDATE invites SET revoked_at = now() WHERE revoked_at IS NULL AND (id = $1 OR name = $1) RETURNING ${icols}`, [idOrName]);
|
|
2009
2511
|
return r[0] ? toInvite(r[0]) : void 0;
|
|
2010
2512
|
}
|
|
2513
|
+
var ACCESS_CODE_RETENTION_SECONDS = 24 * 60 * 60;
|
|
2514
|
+
var REFRESH_TOKEN_RETENTION_SECONDS = 30 * 24 * 60 * 60;
|
|
2515
|
+
var ORPHAN_CLIENT_AGE_SECONDS = 24 * 60 * 60;
|
|
2516
|
+
async function purgeExpiredAccess(db, now = /* @__PURE__ */ new Date(), deviceGrant = "urn:ietf:params:oauth:grant-type:device_code") {
|
|
2517
|
+
const codeCutoff = new Date(now.getTime() - ACCESS_CODE_RETENTION_SECONDS * 1e3);
|
|
2518
|
+
const refreshCutoff = new Date(now.getTime() - REFRESH_TOKEN_RETENTION_SECONDS * 1e3);
|
|
2519
|
+
const clientCutoff = new Date(now.getTime() - ORPHAN_CLIENT_AGE_SECONDS * 1e3);
|
|
2520
|
+
const dev = await db.query("DELETE FROM oauth_device_codes WHERE expires_at < $1 RETURNING device_code_hash", [codeCutoff]);
|
|
2521
|
+
const auth = await db.query("DELETE FROM oauth_codes WHERE expires_at < $1 RETURNING code_hash", [codeCutoff]);
|
|
2522
|
+
const refresh = await db.query("DELETE FROM oauth_refresh_tokens WHERE expires_at < $1 RETURNING token_hash", [refreshCutoff]);
|
|
2523
|
+
const clients = await db.query(
|
|
2524
|
+
`DELETE FROM oauth_clients c
|
|
2525
|
+
WHERE c.client_secret_hash IS NULL
|
|
2526
|
+
AND cardinality(c.redirect_uris) = 0
|
|
2527
|
+
AND $1 = ANY(c.grant_types)
|
|
2528
|
+
AND c.created_at < $2
|
|
2529
|
+
AND NOT EXISTS (SELECT 1 FROM tokens t WHERE t.client_id = c.client_id)
|
|
2530
|
+
AND NOT EXISTS (SELECT 1 FROM oauth_refresh_tokens r WHERE r.client_id = c.client_id)
|
|
2531
|
+
AND NOT EXISTS (SELECT 1 FROM oauth_device_codes d WHERE d.client_id = c.client_id AND d.expires_at > $3)
|
|
2532
|
+
AND NOT EXISTS (SELECT 1 FROM oauth_codes k WHERE k.client_id = c.client_id AND k.expires_at > $3)
|
|
2533
|
+
RETURNING c.name`,
|
|
2534
|
+
[deviceGrant, clientCutoff, now]
|
|
2535
|
+
);
|
|
2536
|
+
return { device_codes: dev.length, auth_codes: auth.length, refresh_tokens: refresh.length, clients: clients.map((c) => c.name) };
|
|
2537
|
+
}
|
|
2011
2538
|
|
|
2012
2539
|
// ../core/src/db/settings.ts
|
|
2013
2540
|
import { createCipheriv, createDecipheriv, createHash as createHash3, randomBytes as randomBytes5 } from "crypto";
|
|
2014
2541
|
var VERSION = "v1";
|
|
2542
|
+
var MIN_SETTINGS_SECRET_LENGTH = 32;
|
|
2015
2543
|
function keyFor(secret) {
|
|
2016
|
-
if (!secret || secret.length <
|
|
2544
|
+
if (!secret || secret.length < MIN_SETTINGS_SECRET_LENGTH) throw new Error(`RELAY_SESSION_SECRET must be at least ${MIN_SETTINGS_SECRET_LENGTH} characters to seal settings; generate one with \`openssl rand -hex 32\`.`);
|
|
2017
2545
|
return createHash3("sha256").update(`relay-settings:${secret}`).digest();
|
|
2018
2546
|
}
|
|
2019
2547
|
function sealValue(plaintext, secret) {
|
|
@@ -2060,7 +2588,7 @@ async function listSettingKeys(db, prefix) {
|
|
|
2060
2588
|
var LEASE_MS = 30 * 6e4;
|
|
2061
2589
|
var WORKER_OFFLINE_MS = 60 * 6e4;
|
|
2062
2590
|
var WORKER_ONLINE_MS = 2 * 6e4;
|
|
2063
|
-
var W = "id, name, host, lanes, labels, version, last_seen, status, created_at";
|
|
2591
|
+
var W = "id, name, host, lanes, labels, version, last_seen, status, poll_ms, meters, created_at";
|
|
2064
2592
|
var arr = (v) => Array.isArray(v) ? v.map(String) : typeof v === "string" && v.startsWith("{") ? v.slice(1, -1).split(",").filter(Boolean) : [];
|
|
2065
2593
|
var toWorker = (r) => ({
|
|
2066
2594
|
id: r.id,
|
|
@@ -2071,6 +2599,8 @@ var toWorker = (r) => ({
|
|
|
2071
2599
|
version: r.version ?? "",
|
|
2072
2600
|
last_seen: iso(r.last_seen) ?? "",
|
|
2073
2601
|
status: r.status === "offline" ? "offline" : "online",
|
|
2602
|
+
poll_ms: r.poll_ms === null || r.poll_ms === void 0 ? null : num(r.poll_ms),
|
|
2603
|
+
meters: sanitizeMeters(typeof r.meters === "string" ? JSON.parse(r.meters) : r.meters) ?? [],
|
|
2074
2604
|
created_at: iso(r.created_at) ?? ""
|
|
2075
2605
|
});
|
|
2076
2606
|
var C = "id, task_id, worker_id, lease_until, claimed_at, released_at, outcome";
|
|
@@ -2111,7 +2641,13 @@ async function listWorkers(db) {
|
|
|
2111
2641
|
}
|
|
2112
2642
|
async function heartbeatWorker(db, id, opts = {}) {
|
|
2113
2643
|
const now = opts.now ?? Date.now();
|
|
2114
|
-
const r = await db.query(`UPDATE workers SET last_seen = $2::timestamptz, status = $3 WHERE id = $1 RETURNING ${W}`, [
|
|
2644
|
+
const r = await db.query(`UPDATE workers SET last_seen = $2::timestamptz, status = $3, poll_ms = coalesce($4::integer, poll_ms), meters = coalesce($5::jsonb, meters) WHERE id = $1 RETURNING ${W}`, [
|
|
2645
|
+
id,
|
|
2646
|
+
new Date(now).toISOString(),
|
|
2647
|
+
opts.status ?? "online",
|
|
2648
|
+
opts.pollMs && Number.isFinite(opts.pollMs) ? Math.round(opts.pollMs) : null,
|
|
2649
|
+
opts.meters ? JSON.stringify(sanitizeMeters(opts.meters) ?? []) : null
|
|
2650
|
+
]);
|
|
2115
2651
|
if (!r[0]) return void 0;
|
|
2116
2652
|
const leases = await db.query(`UPDATE claims SET lease_until = $2::timestamptz WHERE worker_id = $1 AND released_at IS NULL RETURNING ${C}`, [id, new Date(now + (opts.leaseMs ?? LEASE_MS)).toISOString()]);
|
|
2117
2653
|
return { worker: toWorker(r[0]), leases: leases.map(toClaim) };
|
|
@@ -2122,8 +2658,34 @@ async function markOffline(db, olderThanMs = WORKER_OFFLINE_MS, now = Date.now()
|
|
|
2122
2658
|
}
|
|
2123
2659
|
async function expireLeases(db, now = Date.now()) {
|
|
2124
2660
|
const r = await db.query(`UPDATE claims SET released_at = $1::timestamptz, outcome = 'expired' WHERE released_at IS NULL AND lease_until < $1::timestamptz RETURNING ${C}`, [new Date(now).toISOString()]);
|
|
2661
|
+
if (r.length) {
|
|
2662
|
+
for (const c of r) await refreshTaskState(db, c.task_id, now);
|
|
2663
|
+
await bumpQueueVersion(db);
|
|
2664
|
+
}
|
|
2125
2665
|
return r.map(toClaim);
|
|
2126
2666
|
}
|
|
2667
|
+
var QUEUE_KINDS2 = ["issue.labeled", "run.started", "run.dispatched", "run.failed", "run.finished", "pr.opened", "pr.merged", "pr.closed", "issue.closed", "task.approved", "task.changes_requested", "task.requeued", "task.held", "task.released"];
|
|
2668
|
+
async function queuedCount(db, opts = {}) {
|
|
2669
|
+
const now = new Date(opts.now ?? Date.now()).toISOString();
|
|
2670
|
+
const lanes = (opts.lanes ?? []).map((l) => l.trim()).filter(Boolean);
|
|
2671
|
+
const params = [now, QUEUE_KINDS2];
|
|
2672
|
+
const laneFilter = lanes.length ? (params.push(lanes), `AND t.lane = ANY($3::text[])`) : `AND t.lane NOT LIKE '%-cloud'`;
|
|
2673
|
+
const r = await db.query(
|
|
2674
|
+
`SELECT count(*) AS n FROM tasks t
|
|
2675
|
+
LEFT JOIN LATERAL (SELECT e.kind FROM events e WHERE e.repo = t.repo AND e.issue = t.issue AND e.kind = ANY($2::text[]) ORDER BY e.ts DESC, e.id DESC LIMIT 1) le ON true
|
|
2676
|
+
WHERE t.lane IS NOT NULL ${laneFilter}
|
|
2677
|
+
AND NOT EXISTS (SELECT 1 FROM claims c WHERE c.task_id = t.id AND c.released_at IS NULL AND c.lease_until > $1::timestamptz)
|
|
2678
|
+
AND (le.kind IS NULL OR le.kind IN ('issue.labeled', 'pr.closed', 'task.changes_requested', 'task.requeued', 'task.released')
|
|
2679
|
+
OR (le.kind = 'run.started' AND EXISTS (SELECT 1 FROM claims c WHERE c.task_id = t.id)))`,
|
|
2680
|
+
params
|
|
2681
|
+
);
|
|
2682
|
+
return num(r[0]?.n);
|
|
2683
|
+
}
|
|
2684
|
+
async function doorbell(db, opts = {}) {
|
|
2685
|
+
const now = opts.now ?? Date.now();
|
|
2686
|
+
const [version, queued] = await Promise.all([getQueueVersion(db), queuedCount(db, { ...opts.lanes ? { lanes: opts.lanes } : {}, now })]);
|
|
2687
|
+
return { version, queued, ts: new Date(now).toISOString() };
|
|
2688
|
+
}
|
|
2127
2689
|
function workerState(w, now = Date.now()) {
|
|
2128
2690
|
if (w.status === "offline") return "offline";
|
|
2129
2691
|
const age = now - new Date(w.last_seen).getTime();
|
|
@@ -2154,6 +2716,10 @@ async function liveClaim(db, taskId2, workerId) {
|
|
|
2154
2716
|
}
|
|
2155
2717
|
async function releaseClaim(db, taskId2, workerId, outcome, now = Date.now()) {
|
|
2156
2718
|
const r = await db.query(`UPDATE claims SET released_at = $3::timestamptz, outcome = $4 WHERE task_id = $1 AND worker_id = $2 AND released_at IS NULL RETURNING ${C}`, [taskId2, workerId, new Date(now).toISOString(), outcome]);
|
|
2719
|
+
if (r[0]) {
|
|
2720
|
+
await refreshTaskState(db, taskId2, now);
|
|
2721
|
+
await bumpQueueVersion(db);
|
|
2722
|
+
}
|
|
2157
2723
|
return r[0] ? toClaim(r[0]) : void 0;
|
|
2158
2724
|
}
|
|
2159
2725
|
function requiredLabels(prefs, project, lane) {
|
|
@@ -2190,7 +2756,10 @@ async function claimNext(db, req) {
|
|
|
2190
2756
|
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
2191
2757
|
if (deriveTaskStatus(task, events, claims, now).state !== "queued") continue;
|
|
2192
2758
|
const claim = await takeClaim(db, task.id, req.workerId, now + (req.leaseMs ?? LEASE_MS), now);
|
|
2193
|
-
if (claim)
|
|
2759
|
+
if (claim) {
|
|
2760
|
+
await refreshTaskState(db, task.id, now);
|
|
2761
|
+
return { claim, task: { ...task, lane } };
|
|
2762
|
+
}
|
|
2194
2763
|
}
|
|
2195
2764
|
return void 0;
|
|
2196
2765
|
}
|
|
@@ -2258,306 +2827,47 @@ async function embedTexts(texts, cfg, ledger, tag2) {
|
|
|
2258
2827
|
return out;
|
|
2259
2828
|
}
|
|
2260
2829
|
|
|
2261
|
-
// ../core/src/
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
const cur = byKey.get(p.key);
|
|
2278
|
-
if (!cur || SCOPE_RANK[p.scope] > SCOPE_RANK[cur.scope]) byKey.set(p.key, p);
|
|
2279
|
-
}
|
|
2280
|
-
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
2281
|
-
}
|
|
2282
|
-
function renderPreference(p) {
|
|
2283
|
-
const where = p.scope === "global" ? "" : ` (${p.scope}${p.scope_id ? `: ${p.scope_id}` : ""})`;
|
|
2284
|
-
return `- ${p.key} = ${p.value}${where}${p.reason ? ` \u2014 ${p.reason}` : ""}`;
|
|
2285
|
-
}
|
|
2286
|
-
function renderDecision(d) {
|
|
2287
|
-
const src = d.source ? ` [${d.source}${d.source_ref ? ` ${shortRef(d.source_ref)}` : ""}]` : "";
|
|
2288
|
-
return `- ${d.text}${src}`;
|
|
2830
|
+
// ../core/src/github/client.ts
|
|
2831
|
+
import { execFile } from "child_process";
|
|
2832
|
+
import { delimiter, join as join6 } from "path";
|
|
2833
|
+
import { statSync } from "fs";
|
|
2834
|
+
|
|
2835
|
+
// ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
2836
|
+
var encoder = new TextEncoder();
|
|
2837
|
+
var decoder = new TextDecoder();
|
|
2838
|
+
var strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
2839
|
+
var MAX_INT32 = 2 ** 32;
|
|
2840
|
+
function concat(...buffers) {
|
|
2841
|
+
const size = buffers.reduce((acc, { length }) => acc + length, 0), buf = new Uint8Array(size);
|
|
2842
|
+
let i = 0;
|
|
2843
|
+
for (const buffer of buffers)
|
|
2844
|
+
buf.set(buffer, i), i += buffer.length;
|
|
2845
|
+
return buf;
|
|
2289
2846
|
}
|
|
2290
|
-
var
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
if (v) hits = await searchDecisions(db, project, v, k);
|
|
2847
|
+
var NON_ASCII = /[^\x00-\x7f]/;
|
|
2848
|
+
function encode(string) {
|
|
2849
|
+
if (typeof string == "string" && string.length >= 128) {
|
|
2850
|
+
if (NON_ASCII.test(string))
|
|
2851
|
+
throw new TypeError("non-ASCII string encountered in encode()");
|
|
2852
|
+
return encoder.encode(string);
|
|
2297
2853
|
}
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
const
|
|
2301
|
-
|
|
2302
|
-
|
|
2854
|
+
const bytes = new Uint8Array(string.length);
|
|
2855
|
+
for (let i = 0; i < string.length; i++) {
|
|
2856
|
+
const code = string.charCodeAt(i);
|
|
2857
|
+
if (code > 127)
|
|
2858
|
+
throw new TypeError("non-ASCII string encountered in encode()");
|
|
2859
|
+
bytes[i] = code;
|
|
2303
2860
|
}
|
|
2304
|
-
|
|
2305
|
-
const seen = new Set(hits.map((h) => h.id));
|
|
2306
|
-
for (const g of globals) if (!seen.has(g.id)) hits.push(g);
|
|
2307
|
-
return hits;
|
|
2861
|
+
return bytes;
|
|
2308
2862
|
}
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
return hits;
|
|
2318
|
-
}
|
|
2319
|
-
function fitItems(items, render, budget) {
|
|
2320
|
-
const lines = [];
|
|
2321
|
-
let used = 0;
|
|
2322
|
-
for (const it of items) {
|
|
2323
|
-
const line = render(it);
|
|
2324
|
-
const t = approxTokens(line + "\n");
|
|
2325
|
-
if (used + t > budget) continue;
|
|
2326
|
-
lines.push(line);
|
|
2327
|
-
used += t;
|
|
2328
|
-
}
|
|
2329
|
-
return { lines, tokens: used };
|
|
2330
|
-
}
|
|
2331
|
-
var heading = (title) => `## ${title}
|
|
2332
|
-
|
|
2333
|
-
`;
|
|
2334
|
-
async function compileBrief(db, opts) {
|
|
2335
|
-
const order = opts.sections ?? DEFAULT_ORDER;
|
|
2336
|
-
const query = [opts.issue?.title, opts.issue?.body, opts.intent].filter(Boolean).join("\n").slice(0, 4e3);
|
|
2337
|
-
const project = await getProject(db, opts.project);
|
|
2338
|
-
const rendered = /* @__PURE__ */ new Map();
|
|
2339
|
-
const omitted = [];
|
|
2340
|
-
let remaining = Math.max(0, opts.maxTokens);
|
|
2341
|
-
for (const name of order) {
|
|
2342
|
-
let text3 = "";
|
|
2343
|
-
if (name === "summary") {
|
|
2344
|
-
if (project?.summary) text3 = heading(`Project: ${project.slug}`) + project.summary.trim() + "\n";
|
|
2345
|
-
const t = approxTokens(text3);
|
|
2346
|
-
if (!text3 || t > remaining) {
|
|
2347
|
-
omitted.push(name);
|
|
2348
|
-
continue;
|
|
2349
|
-
}
|
|
2350
|
-
rendered.set(name, { text: text3, tokens: t });
|
|
2351
|
-
remaining -= t;
|
|
2352
|
-
} else if (name === "preferences") {
|
|
2353
|
-
const prefs = await mergedPreferences(db, { project: opts.project, ...opts.lane ? { lane: opts.lane } : {}, ...opts.model ? { model: opts.model } : {} });
|
|
2354
|
-
if (prefs.length) text3 = heading("Preferences (hard rules)") + prefs.map(renderPreference).join("\n") + "\n";
|
|
2355
|
-
const t = approxTokens(text3);
|
|
2356
|
-
if (!text3 || t > remaining) {
|
|
2357
|
-
omitted.push(name);
|
|
2358
|
-
continue;
|
|
2359
|
-
}
|
|
2360
|
-
rendered.set(name, { text: text3, tokens: t });
|
|
2361
|
-
remaining -= t;
|
|
2362
|
-
} else if (name === "decisions") {
|
|
2363
|
-
const head = heading("Current decisions");
|
|
2364
|
-
const headT = approxTokens(head);
|
|
2365
|
-
const items = await matchingDecisions(db, opts.project, query, { embed: opts.embed, ...opts.k ? { k: opts.k } : {} });
|
|
2366
|
-
const fit = fitItems(items, renderDecision, remaining - headT);
|
|
2367
|
-
if (!fit.lines.length) {
|
|
2368
|
-
omitted.push(name);
|
|
2369
|
-
continue;
|
|
2370
|
-
}
|
|
2371
|
-
text3 = head + fit.lines.join("\n") + "\n";
|
|
2372
|
-
const t = approxTokens(text3);
|
|
2373
|
-
rendered.set(name, { text: text3, tokens: t });
|
|
2374
|
-
remaining -= t;
|
|
2375
|
-
} else if (name === "guidance") {
|
|
2376
|
-
if (!opts.model) {
|
|
2377
|
-
omitted.push(name);
|
|
2378
|
-
continue;
|
|
2379
|
-
}
|
|
2380
|
-
const head = heading(`Vendor guidance for ${opts.model}`);
|
|
2381
|
-
const headT = approxTokens(head);
|
|
2382
|
-
const hits = await matchingGuidance(db, opts.model, query, { embed: opts.embed, ...opts.k ? { k: opts.k } : {} });
|
|
2383
|
-
const fit = fitItems(hits, (h) => `### ${h.title} (${h.kind})
|
|
2384
|
-
${h.text.trim()}
|
|
2385
|
-
Source: ${h.source_url}
|
|
2386
|
-
`, remaining - headT);
|
|
2387
|
-
if (!fit.lines.length) {
|
|
2388
|
-
omitted.push(name);
|
|
2389
|
-
continue;
|
|
2390
|
-
}
|
|
2391
|
-
text3 = head + fit.lines.join("\n") + "\n";
|
|
2392
|
-
const t = approxTokens(text3);
|
|
2393
|
-
rendered.set(name, { text: text3, tokens: t });
|
|
2394
|
-
remaining -= t;
|
|
2395
|
-
}
|
|
2396
|
-
}
|
|
2397
|
-
const parts = [];
|
|
2398
|
-
const sections = [];
|
|
2399
|
-
for (const name of RENDER_ORDER) {
|
|
2400
|
-
const r = rendered.get(name);
|
|
2401
|
-
if (!r) continue;
|
|
2402
|
-
parts.push(r.text);
|
|
2403
|
-
sections.push({ name, tokens: r.tokens });
|
|
2404
|
-
}
|
|
2405
|
-
const text2 = parts.join("\n").trimEnd() + (parts.length ? "\n" : "");
|
|
2406
|
-
return { text: text2, sections, tokens: approxTokens(text2), omitted };
|
|
2407
|
-
}
|
|
2408
|
-
function briefEmbedder(h) {
|
|
2409
|
-
if (!h.cfg.gateway.apiKey) return void 0;
|
|
2410
|
-
return async (text2) => {
|
|
2411
|
-
const v = await embedTexts([text2], h.cfg, h.ledger, "brief");
|
|
2412
|
-
return v?.[0] ?? null;
|
|
2413
|
-
};
|
|
2414
|
-
}
|
|
2415
|
-
|
|
2416
|
-
// ../core/src/client/device.ts
|
|
2417
|
-
var OAuthClientError = class extends Error {
|
|
2418
|
-
constructor(code, description, status) {
|
|
2419
|
-
super(description || code);
|
|
2420
|
-
this.code = code;
|
|
2421
|
-
this.description = description;
|
|
2422
|
-
this.status = status;
|
|
2423
|
-
this.name = "OAuthClientError";
|
|
2424
|
-
}
|
|
2425
|
-
code;
|
|
2426
|
-
description;
|
|
2427
|
-
status;
|
|
2428
|
-
};
|
|
2429
|
-
var defaultFetch = (u, i) => fetch(u, i);
|
|
2430
|
-
async function post(fetchImpl, url, body) {
|
|
2431
|
-
const form = new URLSearchParams();
|
|
2432
|
-
for (const [k, v] of Object.entries(body)) if (v !== void 0 && v !== "") form.set(k, v);
|
|
2433
|
-
const res = await fetchImpl(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", "user-agent": "relay-cli" }, body: form.toString() });
|
|
2434
|
-
const text2 = await res.text();
|
|
2435
|
-
let json2 = {};
|
|
2436
|
-
try {
|
|
2437
|
-
json2 = text2 ? JSON.parse(text2) : {};
|
|
2438
|
-
} catch {
|
|
2439
|
-
json2 = { error: "server_error", error_description: text2.slice(0, 200) };
|
|
2440
|
-
}
|
|
2441
|
-
if (!res.ok) throw new OAuthClientError(String(json2.error ?? `http_${res.status}`), String(json2.error_description ?? json2.detail ?? ""), res.status);
|
|
2442
|
-
return json2;
|
|
2443
|
-
}
|
|
2444
|
-
var asToken = (j) => {
|
|
2445
|
-
if (typeof j.access_token !== "string") throw new OAuthClientError("server_error", "The token response carried no access_token.", 502);
|
|
2446
|
-
return {
|
|
2447
|
-
access_token: j.access_token,
|
|
2448
|
-
token_type: String(j.token_type ?? "Bearer"),
|
|
2449
|
-
expires_in: Number(j.expires_in ?? 0),
|
|
2450
|
-
...typeof j.refresh_token === "string" ? { refresh_token: j.refresh_token } : {},
|
|
2451
|
-
...typeof j.scope === "string" ? { scope: j.scope } : {},
|
|
2452
|
-
...typeof j.token_name === "string" ? { token_name: j.token_name } : {},
|
|
2453
|
-
...typeof j.expires_at === "string" ? { expires_at: j.expires_at } : {}
|
|
2454
|
-
};
|
|
2455
|
-
};
|
|
2456
|
-
async function refreshAccessToken(opts) {
|
|
2457
|
-
return asToken(await post(opts.fetch ?? defaultFetch, `${normalizeSite(opts.site)}/oauth/token`, { grant_type: "refresh_token", refresh_token: opts.refreshToken }));
|
|
2458
|
-
}
|
|
2459
|
-
function credentialsFromToken(r, now = Date.now()) {
|
|
2460
|
-
const expiresAt = r.expires_at ?? (r.expires_in > 0 ? new Date(now + r.expires_in * 1e3).toISOString() : null);
|
|
2461
|
-
return { token: r.access_token, refreshToken: r.refresh_token ?? null, expiresAt };
|
|
2462
|
-
}
|
|
2463
|
-
function tokenIsStale(expiresAt, now = Date.now(), marginMs = 12e4) {
|
|
2464
|
-
if (!expiresAt) return false;
|
|
2465
|
-
const t = new Date(expiresAt).getTime();
|
|
2466
|
-
return Number.isFinite(t) && t - now < marginMs;
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
|
-
// ../core/src/db/migrate.ts
|
|
2470
|
-
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync3 } from "fs";
|
|
2471
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2472
|
-
import { dirname as dirname3, join as join5 } from "path";
|
|
2473
|
-
var beside = join5(dirname3(fileURLToPath2(import.meta.url)), "migrations");
|
|
2474
|
-
var MIGRATIONS_DIR = existsSync4(beside) ? beside : runtimePaths().migrationsDir;
|
|
2475
|
-
function loadMigrations(dir = MIGRATIONS_DIR) {
|
|
2476
|
-
const out = [];
|
|
2477
|
-
for (const f of readdirSync(dir).sort()) {
|
|
2478
|
-
const m = /^(\d{3,})_([\w-]+)\.sql$/.exec(f);
|
|
2479
|
-
if (!m) continue;
|
|
2480
|
-
out.push({ version: Number(m[1]), name: m[2], sql: readFileSync3(join5(dir, f), "utf8") });
|
|
2481
|
-
}
|
|
2482
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2483
|
-
for (const m of out) {
|
|
2484
|
-
if (seen.has(m.version)) throw new Error(`duplicate migration version ${m.version}`);
|
|
2485
|
-
seen.add(m.version);
|
|
2486
|
-
}
|
|
2487
|
-
return out;
|
|
2488
|
-
}
|
|
2489
|
-
var META_DDL = `CREATE TABLE IF NOT EXISTS meta (
|
|
2490
|
-
key text PRIMARY KEY,
|
|
2491
|
-
value jsonb NOT NULL,
|
|
2492
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
2493
|
-
)`;
|
|
2494
|
-
async function currentVersion(db) {
|
|
2495
|
-
await db.exec(META_DDL);
|
|
2496
|
-
const r = await db.query("SELECT value FROM meta WHERE key = 'schema_version'");
|
|
2497
|
-
const v = r[0]?.value;
|
|
2498
|
-
return typeof v === "number" ? v : typeof v === "string" ? Number(v) || 0 : 0;
|
|
2499
|
-
}
|
|
2500
|
-
async function migrate(db, opts = {}) {
|
|
2501
|
-
const log = opts.log ?? noopLog;
|
|
2502
|
-
const have = await currentVersion(db);
|
|
2503
|
-
let applied = have;
|
|
2504
|
-
for (const m of loadMigrations(opts.dir)) {
|
|
2505
|
-
if (m.version <= have) continue;
|
|
2506
|
-
log(`schema: applying ${String(m.version).padStart(3, "0")}_${m.name}`);
|
|
2507
|
-
await db.transaction(async (tx) => {
|
|
2508
|
-
await tx.exec(m.sql);
|
|
2509
|
-
await tx.query(
|
|
2510
|
-
`INSERT INTO meta (key, value, updated_at) VALUES ('schema_version', $1::jsonb, now())
|
|
2511
|
-
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
|
2512
|
-
[JSON.stringify(m.version)]
|
|
2513
|
-
);
|
|
2514
|
-
});
|
|
2515
|
-
applied = m.version;
|
|
2516
|
-
}
|
|
2517
|
-
return applied;
|
|
2518
|
-
}
|
|
2519
|
-
|
|
2520
|
-
// ../core/src/github/client.ts
|
|
2521
|
-
import { execFile } from "child_process";
|
|
2522
|
-
import { delimiter, join as join6 } from "path";
|
|
2523
|
-
import { statSync } from "fs";
|
|
2524
|
-
|
|
2525
|
-
// ../../node_modules/.pnpm/jose@6.2.12/node_modules/jose/dist/webapi/lib/buffer_utils.js
|
|
2526
|
-
var encoder = new TextEncoder();
|
|
2527
|
-
var decoder = new TextDecoder();
|
|
2528
|
-
var strictDecoder = new TextDecoder("utf-8", { fatal: true });
|
|
2529
|
-
var MAX_INT32 = 2 ** 32;
|
|
2530
|
-
function concat(...buffers) {
|
|
2531
|
-
const size = buffers.reduce((acc, { length }) => acc + length, 0), buf = new Uint8Array(size);
|
|
2532
|
-
let i = 0;
|
|
2533
|
-
for (const buffer of buffers)
|
|
2534
|
-
buf.set(buffer, i), i += buffer.length;
|
|
2535
|
-
return buf;
|
|
2536
|
-
}
|
|
2537
|
-
var NON_ASCII = /[^\x00-\x7f]/;
|
|
2538
|
-
function encode(string) {
|
|
2539
|
-
if (typeof string == "string" && string.length >= 128) {
|
|
2540
|
-
if (NON_ASCII.test(string))
|
|
2541
|
-
throw new TypeError("non-ASCII string encountered in encode()");
|
|
2542
|
-
return encoder.encode(string);
|
|
2543
|
-
}
|
|
2544
|
-
const bytes = new Uint8Array(string.length);
|
|
2545
|
-
for (let i = 0; i < string.length; i++) {
|
|
2546
|
-
const code = string.charCodeAt(i);
|
|
2547
|
-
if (code > 127)
|
|
2548
|
-
throw new TypeError("non-ASCII string encountered in encode()");
|
|
2549
|
-
bytes[i] = code;
|
|
2550
|
-
}
|
|
2551
|
-
return bytes;
|
|
2552
|
-
}
|
|
2553
|
-
function encodeBase64(input, url = false) {
|
|
2554
|
-
if (Uint8Array.prototype.toBase64)
|
|
2555
|
-
return input.toBase64({ alphabet: url ? "base64url" : "base64", omitPadding: url });
|
|
2556
|
-
const CHUNK_SIZE = 32768, arr2 = [];
|
|
2557
|
-
for (let i = 0; i < input.length; i += CHUNK_SIZE)
|
|
2558
|
-
arr2.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)));
|
|
2559
|
-
const encoded = btoa(arr2.join(""));
|
|
2560
|
-
return url ? encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") : encoded;
|
|
2863
|
+
function encodeBase64(input, url = false) {
|
|
2864
|
+
if (Uint8Array.prototype.toBase64)
|
|
2865
|
+
return input.toBase64({ alphabet: url ? "base64url" : "base64", omitPadding: url });
|
|
2866
|
+
const CHUNK_SIZE = 32768, arr2 = [];
|
|
2867
|
+
for (let i = 0; i < input.length; i += CHUNK_SIZE)
|
|
2868
|
+
arr2.push(String.fromCharCode.apply(null, input.subarray(i, i + CHUNK_SIZE)));
|
|
2869
|
+
const encoded = btoa(arr2.join(""));
|
|
2870
|
+
return url ? encoded.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") : encoded;
|
|
2561
2871
|
}
|
|
2562
2872
|
function decodeBase64(encoded, url = false) {
|
|
2563
2873
|
if (Uint8Array.fromBase64)
|
|
@@ -3364,8 +3674,12 @@ var GitHubApp = class {
|
|
|
3364
3674
|
source: "app",
|
|
3365
3675
|
createIssue: async (repo, input) => (await perRepo(repo)).createIssue(repo, input),
|
|
3366
3676
|
addLabels: async (repo, issue, labels) => (await perRepo(repo)).addLabels(repo, issue, labels),
|
|
3677
|
+
removeLabel: async (repo, issue, label) => (await perRepo(repo)).removeLabel(repo, issue, label),
|
|
3367
3678
|
getIssue: async (repo, issue) => (await perRepo(repo)).getIssue(repo, issue),
|
|
3368
3679
|
createComment: async (repo, issue, body) => (await perRepo(repo)).createComment(repo, issue, body),
|
|
3680
|
+
getPullRequest: async (repo, number) => (await perRepo(repo)).getPullRequest(repo, number),
|
|
3681
|
+
mergePullRequest: async (repo, number, o) => (await perRepo(repo)).mergePullRequest(repo, number, o),
|
|
3682
|
+
deleteBranch: async (repo, ref) => (await perRepo(repo)).deleteBranch(repo, ref),
|
|
3369
3683
|
request: async (method, path, body) => (await perRepo(repoOfPath(path))).request(method, path, body)
|
|
3370
3684
|
};
|
|
3371
3685
|
}
|
|
@@ -3373,6 +3687,62 @@ var GitHubApp = class {
|
|
|
3373
3687
|
|
|
3374
3688
|
// ../core/src/github/client.ts
|
|
3375
3689
|
var toComment = (j) => ({ id: Number(j.id ?? 0), html_url: String(j.html_url ?? "") });
|
|
3690
|
+
function toPull(j) {
|
|
3691
|
+
const head = j.head ?? {};
|
|
3692
|
+
const base = j.base ?? {};
|
|
3693
|
+
const user = j.user ?? {};
|
|
3694
|
+
return {
|
|
3695
|
+
number: Number(j.number),
|
|
3696
|
+
title: String(j.title ?? ""),
|
|
3697
|
+
state: String(j.state ?? ""),
|
|
3698
|
+
merged: Boolean(j.merged),
|
|
3699
|
+
html_url: String(j.html_url ?? ""),
|
|
3700
|
+
head_ref: String(head.ref ?? ""),
|
|
3701
|
+
head_sha: String(head.sha ?? ""),
|
|
3702
|
+
base_ref: String(base.ref ?? ""),
|
|
3703
|
+
user: String(user.login ?? "")
|
|
3704
|
+
};
|
|
3705
|
+
}
|
|
3706
|
+
var toMerge = (j) => ({ sha: String(j.sha ?? ""), merged: Boolean(j.merged), message: String(j.message ?? "") });
|
|
3707
|
+
var mergeBody = (o) => ({
|
|
3708
|
+
merge_method: o.method ?? "squash",
|
|
3709
|
+
...o.title ? { commit_title: o.title } : {},
|
|
3710
|
+
...o.message ? { commit_message: o.message } : {},
|
|
3711
|
+
...o.sha ? { sha: o.sha } : {}
|
|
3712
|
+
});
|
|
3713
|
+
async function removeLabelWith(call, repo, issue, label) {
|
|
3714
|
+
assertRepo(repo);
|
|
3715
|
+
if (!label.trim()) return;
|
|
3716
|
+
try {
|
|
3717
|
+
await call("DELETE", `/repos/${repo}/issues/${issue}/labels/${encodeURIComponent(label)}`);
|
|
3718
|
+
} catch (e) {
|
|
3719
|
+
if (e instanceof GitHubError && e.status === 404) return;
|
|
3720
|
+
throw e;
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
function pullOps(call) {
|
|
3724
|
+
return {
|
|
3725
|
+
async getPullRequest(repo, number) {
|
|
3726
|
+
assertRepo(repo);
|
|
3727
|
+
return toPull(await call("GET", `/repos/${repo}/pulls/${number}`));
|
|
3728
|
+
},
|
|
3729
|
+
async mergePullRequest(repo, number, opts = {}) {
|
|
3730
|
+
assertRepo(repo);
|
|
3731
|
+
return toMerge(await call("PUT", `/repos/${repo}/pulls/${number}/merge`, mergeBody(opts)));
|
|
3732
|
+
},
|
|
3733
|
+
async deleteBranch(repo, ref) {
|
|
3734
|
+
assertRepo(repo);
|
|
3735
|
+
const name = ref.replace(/^refs\/heads\//, "");
|
|
3736
|
+
if (!name || /[\s~^:?*[\\]/.test(name)) throw new GitHubError(`branch name is not valid: ${ref}`);
|
|
3737
|
+
try {
|
|
3738
|
+
await call("DELETE", `/repos/${repo}/git/refs/heads/${name}`);
|
|
3739
|
+
} catch (e) {
|
|
3740
|
+
if (e instanceof GitHubError && (e.status === 404 || e.status === 422)) return;
|
|
3741
|
+
throw e;
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3376
3746
|
var GitHubError = class extends Error {
|
|
3377
3747
|
constructor(message2, status) {
|
|
3378
3748
|
super(message2);
|
|
@@ -3411,6 +3781,7 @@ function githubFromToken(token, fetchImpl = (u, i) => fetch(u, i), apiUrl = API)
|
|
|
3411
3781
|
if (!labels.length) return;
|
|
3412
3782
|
await call("POST", `/repos/${repo}/issues/${issue}/labels`, { labels });
|
|
3413
3783
|
},
|
|
3784
|
+
removeLabel: (repo, issue, label) => removeLabelWith(call, repo, issue, label),
|
|
3414
3785
|
async getIssue(repo, issue) {
|
|
3415
3786
|
assertRepo(repo);
|
|
3416
3787
|
return toIssue(await call("GET", `/repos/${repo}/issues/${issue}`));
|
|
@@ -3419,7 +3790,8 @@ function githubFromToken(token, fetchImpl = (u, i) => fetch(u, i), apiUrl = API)
|
|
|
3419
3790
|
async createComment(repo, issue, body) {
|
|
3420
3791
|
assertRepo(repo);
|
|
3421
3792
|
return toComment(await call("POST", `/repos/${repo}/issues/${issue}/comments`, { body }));
|
|
3422
|
-
}
|
|
3793
|
+
},
|
|
3794
|
+
...pullOps(call)
|
|
3423
3795
|
};
|
|
3424
3796
|
}
|
|
3425
3797
|
var envApp;
|
|
@@ -3446,7 +3818,11 @@ function githubFromGh(bin = findOnPath("gh") ?? "gh") {
|
|
|
3446
3818
|
const args = ["api", "-X", method, "-H", "Accept: application/vnd.github+json", path];
|
|
3447
3819
|
if (body !== void 0) args.push("--input", "-");
|
|
3448
3820
|
const child = execFile(bin, args, { encoding: "utf8", timeout: 3e4, maxBuffer: 4 * 1024 * 1024, env: process.env }, (err, stdout, stderr) => {
|
|
3449
|
-
if (err)
|
|
3821
|
+
if (err) {
|
|
3822
|
+
const first = (stderr || err.message).toString().split("\n")[0] ?? "";
|
|
3823
|
+
const status = /HTTP (\d{3})/.exec(first)?.[1];
|
|
3824
|
+
return rej(new GitHubError(`gh api ${method} ${path} failed: ${first.slice(0, 200)}`, status ? Number(status) : void 0));
|
|
3825
|
+
}
|
|
3450
3826
|
try {
|
|
3451
3827
|
res(stdout.trim() ? JSON.parse(stdout) : {});
|
|
3452
3828
|
} catch (e) {
|
|
@@ -3466,6 +3842,7 @@ function githubFromGh(bin = findOnPath("gh") ?? "gh") {
|
|
|
3466
3842
|
if (!labels.length) return;
|
|
3467
3843
|
await api("POST", `/repos/${repo}/issues/${issue}/labels`, { labels });
|
|
3468
3844
|
},
|
|
3845
|
+
removeLabel: (repo, issue, label) => removeLabelWith(api, repo, issue, label),
|
|
3469
3846
|
async getIssue(repo, issue) {
|
|
3470
3847
|
assertRepo(repo);
|
|
3471
3848
|
return toIssue(await api("GET", `/repos/${repo}/issues/${issue}`));
|
|
@@ -3474,7 +3851,8 @@ function githubFromGh(bin = findOnPath("gh") ?? "gh") {
|
|
|
3474
3851
|
async createComment(repo, issue, body) {
|
|
3475
3852
|
assertRepo(repo);
|
|
3476
3853
|
return toComment(await api("POST", `/repos/${repo}/issues/${issue}/comments`, { body }));
|
|
3477
|
-
}
|
|
3854
|
+
},
|
|
3855
|
+
...pullOps(api)
|
|
3478
3856
|
};
|
|
3479
3857
|
}
|
|
3480
3858
|
function detectGitHub(env = process.env, opts = {}) {
|
|
@@ -3490,57 +3868,838 @@ function detectGitHub(env = process.env, opts = {}) {
|
|
|
3490
3868
|
return void 0;
|
|
3491
3869
|
}
|
|
3492
3870
|
|
|
3493
|
-
// ../core/src/
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3871
|
+
// ../core/src/tasks/review.ts
|
|
3872
|
+
var TaskReviewError = class extends Error {
|
|
3873
|
+
constructor(message2, status = 400) {
|
|
3874
|
+
super(message2);
|
|
3875
|
+
this.status = status;
|
|
3876
|
+
this.name = "TaskReviewError";
|
|
3877
|
+
}
|
|
3878
|
+
status;
|
|
3879
|
+
};
|
|
3880
|
+
var nameOf = (actor) => actor.slice(actor.indexOf(":") + 1).replace(/^oauth:/, "").toLowerCase();
|
|
3881
|
+
var REVIEWERS_TEXT = "a signed-in person, or a bot whose token holds tasks:write and that did not produce the PR itself";
|
|
3882
|
+
async function assertReviewable(db, task, actor, opts, verb) {
|
|
3883
|
+
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
3884
|
+
const status = deriveTaskStatus(task, events, claims);
|
|
3885
|
+
if (!actor.startsWith("human:")) {
|
|
3886
|
+
if (opts.scopes && !opts.scopes.includes("tasks:write")) throw new TaskReviewError(`${actor} cannot ${verb} ${task.id}: its token lacks tasks:write. Who can: ${REVIEWERS_TEXT}.`, 403);
|
|
3887
|
+
const me = nameOf(actor);
|
|
3888
|
+
const produced = events.some((e) => (e.kind === "run.started" || e.kind === "pr.opened" || e.kind === "run.finished") && nameOf(e.actor) === me);
|
|
3889
|
+
if (produced) throw new TaskReviewError(`${actor} cannot ${verb} ${task.id}: it produced this PR. A bot never approves its own work; who can: ${REVIEWERS_TEXT}.`, 403);
|
|
3890
|
+
}
|
|
3891
|
+
if (status.state !== "review") throw new TaskReviewError(`${task.id} is ${status.state}, not in review; only a task in review with an open PR can be ${verb === "approve" ? "approved" : "sent back"}.`, 409);
|
|
3892
|
+
if (status.pr == null) throw new TaskReviewError(`${task.id} is in review but relay has no pull request for it yet (pr.opened has not arrived).`, 409);
|
|
3893
|
+
return { status, events };
|
|
3894
|
+
}
|
|
3895
|
+
async function loadTask(db, taskId2) {
|
|
3896
|
+
const task = await getTask(db, taskId2.trim());
|
|
3897
|
+
if (!task) throw new TaskReviewError(`No task ${taskId2}.`, 404);
|
|
3898
|
+
return task;
|
|
3899
|
+
}
|
|
3900
|
+
async function approveTask(db, github, taskId2, actor, opts = {}) {
|
|
3901
|
+
const task = await loadTask(db, taskId2);
|
|
3902
|
+
const { status } = await assertReviewable(db, task, actor, opts, "approve");
|
|
3903
|
+
if (!github) throw new TaskReviewError("No GitHub credential: set GITHUB_TOKEN (web) or log in with gh (CLI).", 503);
|
|
3904
|
+
const number = status.pr;
|
|
3905
|
+
const events = [];
|
|
3906
|
+
const base = { actor, project: task.project, repo: task.repo, issue: task.issue, lane: status.lane, pr: number };
|
|
3907
|
+
let pull;
|
|
3908
|
+
try {
|
|
3909
|
+
pull = await github.getPullRequest(task.repo, number);
|
|
3910
|
+
} catch (e) {
|
|
3911
|
+
throw new TaskReviewError(`GitHub could not read PR #${number} of ${task.repo}: ${describe(e)}`, 502);
|
|
3912
|
+
}
|
|
3913
|
+
if (pull.merged) throw new TaskReviewError(`PR #${number} is already merged; relay is waiting for GitHub's pr.merged.`, 409);
|
|
3914
|
+
if (pull.state !== "open") throw new TaskReviewError(`PR #${number} is ${pull.state}, not open.`, 409);
|
|
3915
|
+
const method = opts.method ?? "squash";
|
|
3916
|
+
let merge;
|
|
3917
|
+
try {
|
|
3918
|
+
merge = await github.mergePullRequest(task.repo, number, {
|
|
3919
|
+
method,
|
|
3920
|
+
title: `${pull.title} (#${number})`,
|
|
3921
|
+
message: `relay: approved by ${actor}
|
|
3922
|
+
|
|
3923
|
+
Closes #${task.issue}`,
|
|
3924
|
+
sha: pull.head_sha
|
|
3925
|
+
});
|
|
3926
|
+
} catch (e) {
|
|
3927
|
+
const reason = describe(e);
|
|
3928
|
+
const failed = await writeEvent(db, {
|
|
3929
|
+
...base,
|
|
3930
|
+
kind: "task.merge_failed",
|
|
3931
|
+
summary: `Merge of #${number} refused: ${reason}`.slice(0, 500),
|
|
3932
|
+
url: pull.html_url,
|
|
3933
|
+
payload: { error: reason, method, head_sha: pull.head_sha, status: e instanceof GitHubError ? e.status ?? null : null }
|
|
3934
|
+
});
|
|
3935
|
+
if (failed) events.push(failed);
|
|
3936
|
+
throw new TaskReviewError(`GitHub refused to merge #${number}: ${reason}`, 502);
|
|
3937
|
+
}
|
|
3938
|
+
let branchDeleted = false;
|
|
3939
|
+
if (!opts.keepBranch && pull.head_ref) {
|
|
3940
|
+
try {
|
|
3941
|
+
await github.deleteBranch(task.repo, pull.head_ref);
|
|
3942
|
+
branchDeleted = true;
|
|
3943
|
+
} catch {
|
|
3944
|
+
branchDeleted = false;
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
const approved = await writeEvent(db, {
|
|
3948
|
+
...base,
|
|
3949
|
+
kind: "task.approved",
|
|
3950
|
+
summary: `Approved and merged #${number}${merge.sha ? ` (${merge.sha.slice(0, 7)})` : ""}`,
|
|
3951
|
+
url: pull.html_url,
|
|
3952
|
+
payload: { merged: true, merge_sha: merge.sha, method, branch: pull.head_ref, branch_deleted: branchDeleted, pr_title: pull.title },
|
|
3953
|
+
dedupe_key: `task:${task.id}:approved:${merge.sha || number}`
|
|
3954
|
+
});
|
|
3955
|
+
if (approved) events.push(approved);
|
|
3956
|
+
return { task: await taskView(db, task), events };
|
|
3957
|
+
}
|
|
3958
|
+
async function requestChanges(db, github, taskId2, actor, note, opts = {}) {
|
|
3959
|
+
const text2 = note.trim();
|
|
3960
|
+
if (!text2) throw new TaskReviewError("A note is required: say what should change.", 400);
|
|
3961
|
+
if (text2.length > 1e4) throw new TaskReviewError("The note must be at most 10000 characters.", 400);
|
|
3962
|
+
const task = await loadTask(db, taskId2);
|
|
3963
|
+
const { status } = await assertReviewable(db, task, actor, opts, "request changes on");
|
|
3964
|
+
if (!github) throw new TaskReviewError("No GitHub credential: set GITHUB_TOKEN (web) or log in with gh (CLI).", 503);
|
|
3965
|
+
const number = status.pr;
|
|
3966
|
+
let comment;
|
|
3967
|
+
try {
|
|
3968
|
+
comment = await github.createComment(task.repo, number, `relay: changes requested by ${actor}:
|
|
3969
|
+
|
|
3970
|
+
${text2}
|
|
3971
|
+
`);
|
|
3972
|
+
} catch (e) {
|
|
3973
|
+
throw new TaskReviewError(`GitHub refused the comment on #${number}: ${describe(e)}`, 502);
|
|
3974
|
+
}
|
|
3975
|
+
const events = [];
|
|
3976
|
+
const row = await writeEvent(db, {
|
|
3977
|
+
actor,
|
|
3978
|
+
project: task.project,
|
|
3979
|
+
repo: task.repo,
|
|
3980
|
+
issue: task.issue,
|
|
3981
|
+
lane: status.lane,
|
|
3982
|
+
pr: number,
|
|
3983
|
+
kind: "task.changes_requested",
|
|
3984
|
+
summary: `Changes requested on #${number}: ${text2.split("\n").find((l) => l.trim()) ?? ""}`.slice(0, 500),
|
|
3985
|
+
url: comment.html_url || status.pr_url,
|
|
3986
|
+
payload: { note: text2, comment_id: comment.id, comment_url: comment.html_url, branch: status.branch }
|
|
3987
|
+
});
|
|
3988
|
+
if (row) events.push(row);
|
|
3989
|
+
await bumpQueueVersion(db);
|
|
3990
|
+
return { task: await taskView(db, task), events };
|
|
3991
|
+
}
|
|
3992
|
+
async function reviewNotes(db, task, limit = 5) {
|
|
3993
|
+
const r = await db.query(
|
|
3994
|
+
`SELECT actor, ts, pr, payload FROM events WHERE kind = 'task.changes_requested' AND issue = $3 AND (repo = $1 OR (repo IS NULL AND project = $2)) ORDER BY ts DESC, id DESC LIMIT $4`,
|
|
3995
|
+
[task.repo, task.project, task.issue, limit]
|
|
3996
|
+
);
|
|
3997
|
+
return r.map((x) => {
|
|
3998
|
+
const payload = typeof x.payload === "string" ? JSON.parse(x.payload) : x.payload ?? {};
|
|
3999
|
+
return { by: x.actor, at: x.ts instanceof Date ? x.ts.toISOString() : String(x.ts), pr: x.pr == null ? null : Number(x.pr), note: typeof payload.note === "string" ? payload.note : "" };
|
|
4000
|
+
}).filter((n) => n.note).reverse();
|
|
4001
|
+
}
|
|
4002
|
+
var describe = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
4003
|
+
|
|
4004
|
+
// ../core/src/brief/index.ts
|
|
4005
|
+
var DEFAULT_ORDER = ["review", "preferences", "decisions", "summary", "guidance"];
|
|
4006
|
+
var RENDER_ORDER = ["review", "summary", "preferences", "decisions", "guidance"];
|
|
4007
|
+
var SCOPE_RANK = { global: 0, project: 1, lane: 2, model: 3 };
|
|
4008
|
+
async function mergedPreferences(db, scope) {
|
|
4009
|
+
const all = await listPreferences(db);
|
|
4010
|
+
const applies = (p) => {
|
|
4011
|
+
if (p.scope === "global") return true;
|
|
4012
|
+
if (p.scope === "project") return !!scope.project && p.scope_id === scope.project;
|
|
4013
|
+
if (p.scope === "lane") return !!scope.lane && p.scope_id === scope.lane;
|
|
4014
|
+
if (p.scope === "model") return !!scope.model && p.scope_id === scope.model;
|
|
4015
|
+
return false;
|
|
4016
|
+
};
|
|
4017
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
4018
|
+
for (const p of all) {
|
|
4019
|
+
if (!applies(p)) continue;
|
|
4020
|
+
const cur = byKey.get(p.key);
|
|
4021
|
+
if (!cur || SCOPE_RANK[p.scope] > SCOPE_RANK[cur.scope]) byKey.set(p.key, p);
|
|
4022
|
+
}
|
|
4023
|
+
return [...byKey.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
4024
|
+
}
|
|
4025
|
+
function renderPreference(p) {
|
|
4026
|
+
const where = p.scope === "global" ? "" : ` (${p.scope}${p.scope_id ? `: ${p.scope_id}` : ""})`;
|
|
4027
|
+
return `- ${p.key} = ${p.value}${where}${p.reason ? ` \u2014 ${p.reason}` : ""}`;
|
|
4028
|
+
}
|
|
4029
|
+
function renderDecision(d) {
|
|
4030
|
+
const src = d.source ? ` [${d.source}${d.source_ref ? ` ${shortRef(d.source_ref)}` : ""}]` : "";
|
|
4031
|
+
return `- ${d.text}${src}`;
|
|
4032
|
+
}
|
|
4033
|
+
var shortRef = (ref) => ref.length > 40 ? ref.slice(0, 37) + "\u2026" : ref;
|
|
4034
|
+
async function matchingDecisions(db, project, query, opts = {}) {
|
|
4035
|
+
const k = opts.k ?? 12;
|
|
4036
|
+
let hits = [];
|
|
4037
|
+
if (opts.embed) {
|
|
4038
|
+
const v = await opts.embed(query).catch(() => null);
|
|
4039
|
+
if (v) hits = await searchDecisions(db, project, v, k);
|
|
4040
|
+
}
|
|
4041
|
+
if (!hits.length && query.trim()) hits = await searchDecisions(db, project, query, k);
|
|
4042
|
+
if (hits.length < k) {
|
|
4043
|
+
const seen2 = new Set(hits.map((h) => h.id));
|
|
4044
|
+
const recent = await listDecisions(db, project, { current: true, limit: k });
|
|
4045
|
+
for (const r of recent) if (!seen2.has(r.id) && hits.length < k) hits.push(r);
|
|
4046
|
+
}
|
|
4047
|
+
const globals = await listDecisions(db, null, { current: true, limit: 4 });
|
|
4048
|
+
const seen = new Set(hits.map((h) => h.id));
|
|
4049
|
+
for (const g of globals) if (!seen.has(g.id)) hits.push(g);
|
|
4050
|
+
return hits;
|
|
4051
|
+
}
|
|
4052
|
+
async function matchingGuidance(db, model, query, opts) {
|
|
4053
|
+
const k = opts.k ?? 8;
|
|
4054
|
+
let hits = [];
|
|
4055
|
+
if (opts.embed) {
|
|
4056
|
+
const v = await opts.embed(query).catch(() => null);
|
|
4057
|
+
if (v) hits = await vectorSearchGuidance(db, model, v, k);
|
|
4058
|
+
}
|
|
4059
|
+
if (!hits.length) hits = await textSearchGuidance(db, model, query, k);
|
|
4060
|
+
return hits;
|
|
4061
|
+
}
|
|
4062
|
+
function fitItems(items, render, budget) {
|
|
4063
|
+
const lines = [];
|
|
4064
|
+
let used = 0;
|
|
4065
|
+
for (const it of items) {
|
|
4066
|
+
const line = render(it);
|
|
4067
|
+
const t = approxTokens(line + "\n");
|
|
4068
|
+
if (used + t > budget) continue;
|
|
4069
|
+
lines.push(line);
|
|
4070
|
+
used += t;
|
|
4071
|
+
}
|
|
4072
|
+
return { lines, tokens: used };
|
|
4073
|
+
}
|
|
4074
|
+
var heading = (title) => `## ${title}
|
|
4075
|
+
|
|
4076
|
+
`;
|
|
4077
|
+
async function compileBrief(db, opts) {
|
|
4078
|
+
const order = opts.sections ?? DEFAULT_ORDER;
|
|
4079
|
+
const query = [opts.issue?.title, opts.issue?.body, opts.intent].filter(Boolean).join("\n").slice(0, 4e3);
|
|
4080
|
+
const project = await getProject(db, opts.project);
|
|
4081
|
+
const rendered = /* @__PURE__ */ new Map();
|
|
4082
|
+
const omitted = [];
|
|
4083
|
+
let remaining = Math.max(0, opts.maxTokens);
|
|
4084
|
+
for (const name of order) {
|
|
4085
|
+
let text3 = "";
|
|
4086
|
+
if (name === "review") {
|
|
4087
|
+
if (project && opts.issue?.number != null) {
|
|
4088
|
+
const notes = await reviewNotes(db, { repo: project.repo ?? "", project: project.slug, issue: opts.issue.number });
|
|
4089
|
+
if (notes.length) {
|
|
4090
|
+
text3 = heading("Review feedback") + `A reviewer sent the pull request for #${opts.issue.number} back. Address every point below; the branch and PR already exist and this run replaces the branch.
|
|
4091
|
+
|
|
4092
|
+
` + notes.map((n) => `- ${n.at.slice(0, 16)} ${n.by}${n.pr ? ` on PR #${n.pr}` : ""}:
|
|
4093
|
+
${n.note.trim().replace(/\n/g, "\n ")}`).join("\n") + "\n";
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
const t = approxTokens(text3);
|
|
4097
|
+
if (!text3 || t > remaining) {
|
|
4098
|
+
omitted.push(name);
|
|
4099
|
+
continue;
|
|
4100
|
+
}
|
|
4101
|
+
rendered.set(name, { text: text3, tokens: t });
|
|
4102
|
+
remaining -= t;
|
|
4103
|
+
} else if (name === "summary") {
|
|
4104
|
+
if (project?.summary) text3 = heading(`Project: ${project.slug}`) + project.summary.trim() + "\n";
|
|
4105
|
+
const t = approxTokens(text3);
|
|
4106
|
+
if (!text3 || t > remaining) {
|
|
4107
|
+
omitted.push(name);
|
|
4108
|
+
continue;
|
|
4109
|
+
}
|
|
4110
|
+
rendered.set(name, { text: text3, tokens: t });
|
|
4111
|
+
remaining -= t;
|
|
4112
|
+
} else if (name === "preferences") {
|
|
4113
|
+
const prefs = await mergedPreferences(db, { project: opts.project, ...opts.lane ? { lane: opts.lane } : {}, ...opts.model ? { model: opts.model } : {} });
|
|
4114
|
+
if (prefs.length) text3 = heading("Preferences (hard rules)") + prefs.map(renderPreference).join("\n") + "\n";
|
|
4115
|
+
const t = approxTokens(text3);
|
|
4116
|
+
if (!text3 || t > remaining) {
|
|
4117
|
+
omitted.push(name);
|
|
4118
|
+
continue;
|
|
4119
|
+
}
|
|
4120
|
+
rendered.set(name, { text: text3, tokens: t });
|
|
4121
|
+
remaining -= t;
|
|
4122
|
+
} else if (name === "decisions") {
|
|
4123
|
+
const head = heading("Current decisions");
|
|
4124
|
+
const headT = approxTokens(head);
|
|
4125
|
+
const items = await matchingDecisions(db, opts.project, query, { embed: opts.embed, ...opts.k ? { k: opts.k } : {} });
|
|
4126
|
+
const fit = fitItems(items, renderDecision, remaining - headT);
|
|
4127
|
+
if (!fit.lines.length) {
|
|
4128
|
+
omitted.push(name);
|
|
4129
|
+
continue;
|
|
4130
|
+
}
|
|
4131
|
+
text3 = head + fit.lines.join("\n") + "\n";
|
|
4132
|
+
const t = approxTokens(text3);
|
|
4133
|
+
rendered.set(name, { text: text3, tokens: t });
|
|
4134
|
+
remaining -= t;
|
|
4135
|
+
} else if (name === "guidance") {
|
|
4136
|
+
if (!opts.model) {
|
|
4137
|
+
omitted.push(name);
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
4140
|
+
const head = heading(`Vendor guidance for ${opts.model}`);
|
|
4141
|
+
const headT = approxTokens(head);
|
|
4142
|
+
const hits = await matchingGuidance(db, opts.model, query, { embed: opts.embed, ...opts.k ? { k: opts.k } : {} });
|
|
4143
|
+
const fit = fitItems(hits, (h) => `### ${h.title} (${h.kind})
|
|
4144
|
+
${h.text.trim()}
|
|
4145
|
+
Source: ${h.source_url}
|
|
4146
|
+
`, remaining - headT);
|
|
4147
|
+
if (!fit.lines.length) {
|
|
4148
|
+
omitted.push(name);
|
|
4149
|
+
continue;
|
|
4150
|
+
}
|
|
4151
|
+
text3 = head + fit.lines.join("\n") + "\n";
|
|
4152
|
+
const t = approxTokens(text3);
|
|
4153
|
+
rendered.set(name, { text: text3, tokens: t });
|
|
4154
|
+
remaining -= t;
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
const parts = [];
|
|
4158
|
+
const sections = [];
|
|
4159
|
+
for (const name of RENDER_ORDER) {
|
|
4160
|
+
const r = rendered.get(name);
|
|
4161
|
+
if (!r) continue;
|
|
4162
|
+
parts.push(r.text);
|
|
4163
|
+
sections.push({ name, tokens: r.tokens });
|
|
4164
|
+
}
|
|
4165
|
+
const text2 = parts.join("\n").trimEnd() + (parts.length ? "\n" : "");
|
|
4166
|
+
return { text: text2, sections, tokens: approxTokens(text2), omitted };
|
|
4167
|
+
}
|
|
4168
|
+
function briefEmbedder(h) {
|
|
4169
|
+
if (!h.cfg.gateway.apiKey) return void 0;
|
|
4170
|
+
return async (text2) => {
|
|
4171
|
+
const v = await embedTexts([text2], h.cfg, h.ledger, "brief");
|
|
4172
|
+
return v?.[0] ?? null;
|
|
4173
|
+
};
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
// ../core/src/client/device.ts
|
|
4177
|
+
var OAuthClientError = class extends Error {
|
|
4178
|
+
constructor(code, description, status) {
|
|
4179
|
+
super(description || code);
|
|
4180
|
+
this.code = code;
|
|
4181
|
+
this.description = description;
|
|
4182
|
+
this.status = status;
|
|
4183
|
+
this.name = "OAuthClientError";
|
|
4184
|
+
}
|
|
4185
|
+
code;
|
|
4186
|
+
description;
|
|
4187
|
+
status;
|
|
4188
|
+
};
|
|
4189
|
+
var defaultFetch = (u, i) => fetch(u, i);
|
|
4190
|
+
async function post(fetchImpl, url, body) {
|
|
4191
|
+
const form = new URLSearchParams();
|
|
4192
|
+
for (const [k, v] of Object.entries(body)) if (v !== void 0 && v !== "") form.set(k, v);
|
|
4193
|
+
const res = await fetchImpl(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json", "user-agent": "relay-cli" }, body: form.toString() });
|
|
4194
|
+
const text2 = await res.text();
|
|
4195
|
+
let json2 = {};
|
|
4196
|
+
try {
|
|
4197
|
+
json2 = text2 ? JSON.parse(text2) : {};
|
|
4198
|
+
} catch {
|
|
4199
|
+
json2 = { error: "server_error", error_description: text2.slice(0, 200) };
|
|
4200
|
+
}
|
|
4201
|
+
if (!res.ok) throw new OAuthClientError(String(json2.error ?? `http_${res.status}`), String(json2.error_description ?? json2.detail ?? ""), res.status);
|
|
4202
|
+
return json2;
|
|
4203
|
+
}
|
|
4204
|
+
var asToken = (j) => {
|
|
4205
|
+
if (typeof j.access_token !== "string") throw new OAuthClientError("server_error", "The token response carried no access_token.", 502);
|
|
4206
|
+
return {
|
|
4207
|
+
access_token: j.access_token,
|
|
4208
|
+
token_type: String(j.token_type ?? "Bearer"),
|
|
4209
|
+
expires_in: Number(j.expires_in ?? 0),
|
|
4210
|
+
...typeof j.refresh_token === "string" ? { refresh_token: j.refresh_token } : {},
|
|
4211
|
+
...typeof j.scope === "string" ? { scope: j.scope } : {},
|
|
4212
|
+
...typeof j.token_name === "string" ? { token_name: j.token_name } : {},
|
|
4213
|
+
...typeof j.expires_at === "string" ? { expires_at: j.expires_at } : {}
|
|
4214
|
+
};
|
|
4215
|
+
};
|
|
4216
|
+
async function refreshAccessToken(opts) {
|
|
4217
|
+
return asToken(await post(opts.fetch ?? defaultFetch, `${normalizeSite(opts.site)}/oauth/token`, { grant_type: "refresh_token", refresh_token: opts.refreshToken }));
|
|
4218
|
+
}
|
|
4219
|
+
function credentialsFromToken(r, now = Date.now()) {
|
|
4220
|
+
const expiresAt = r.expires_at ?? (r.expires_in > 0 ? new Date(now + r.expires_in * 1e3).toISOString() : null);
|
|
4221
|
+
return { token: r.access_token, refreshToken: r.refresh_token ?? null, expiresAt };
|
|
4222
|
+
}
|
|
4223
|
+
function tokenIsStale(expiresAt, now = Date.now(), marginMs = 12e4) {
|
|
4224
|
+
if (!expiresAt) return false;
|
|
4225
|
+
const t = new Date(expiresAt).getTime();
|
|
4226
|
+
return Number.isFinite(t) && t - now < marginMs;
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
// ../core/src/db/migrate.ts
|
|
4230
|
+
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
|
|
4231
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4232
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
4233
|
+
var AFTER_APPLY = {
|
|
4234
|
+
10: async (db, log) => {
|
|
4235
|
+
const n = await backfillTaskState(db);
|
|
4236
|
+
if (n) log(`schema: cached the state of ${n} task${n === 1 ? "" : "s"}`);
|
|
4237
|
+
}
|
|
4238
|
+
};
|
|
4239
|
+
var beside = join7(dirname3(fileURLToPath2(import.meta.url)), "migrations");
|
|
4240
|
+
var MIGRATIONS_DIR = existsSync5(beside) ? beside : runtimePaths().migrationsDir;
|
|
4241
|
+
function loadMigrations(dir = MIGRATIONS_DIR) {
|
|
4242
|
+
const out = [];
|
|
4243
|
+
for (const f of readdirSync2(dir).sort()) {
|
|
4244
|
+
const m = /^(\d{3,})_([\w-]+)\.sql$/.exec(f);
|
|
4245
|
+
if (!m) continue;
|
|
4246
|
+
out.push({ version: Number(m[1]), name: m[2], sql: readFileSync4(join7(dir, f), "utf8") });
|
|
4247
|
+
}
|
|
4248
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4249
|
+
for (const m of out) {
|
|
4250
|
+
if (seen.has(m.version)) throw new Error(`duplicate migration version ${m.version}`);
|
|
4251
|
+
seen.add(m.version);
|
|
4252
|
+
}
|
|
4253
|
+
return out;
|
|
4254
|
+
}
|
|
4255
|
+
var META_DDL = `CREATE TABLE IF NOT EXISTS meta (
|
|
4256
|
+
key text PRIMARY KEY,
|
|
4257
|
+
value jsonb NOT NULL,
|
|
4258
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
4259
|
+
)`;
|
|
4260
|
+
async function currentVersion(db) {
|
|
4261
|
+
await db.exec(META_DDL);
|
|
4262
|
+
const r = await db.query("SELECT value FROM meta WHERE key = 'schema_version'");
|
|
4263
|
+
const v = r[0]?.value;
|
|
4264
|
+
return typeof v === "number" ? v : typeof v === "string" ? Number(v) || 0 : 0;
|
|
4265
|
+
}
|
|
4266
|
+
async function migrate(db, opts = {}) {
|
|
4267
|
+
const log = opts.log ?? noopLog;
|
|
4268
|
+
const have = await currentVersion(db);
|
|
4269
|
+
let applied = have;
|
|
4270
|
+
for (const m of loadMigrations(opts.dir)) {
|
|
4271
|
+
if (m.version <= have) continue;
|
|
4272
|
+
log(`schema: applying ${String(m.version).padStart(3, "0")}_${m.name}`);
|
|
4273
|
+
await db.transaction(async (tx) => {
|
|
4274
|
+
await tx.exec(m.sql);
|
|
4275
|
+
await tx.query(
|
|
4276
|
+
`INSERT INTO meta (key, value, updated_at) VALUES ('schema_version', $1::jsonb, now())
|
|
4277
|
+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
|
4278
|
+
[JSON.stringify(m.version)]
|
|
4279
|
+
);
|
|
4280
|
+
});
|
|
4281
|
+
await AFTER_APPLY[m.version]?.(db, log);
|
|
4282
|
+
applied = m.version;
|
|
4283
|
+
}
|
|
4284
|
+
return applied;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
// ../core/src/oauth/server.ts
|
|
4288
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
4289
|
+
var ACCESS_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
4290
|
+
var REFRESH_TOKEN_TTL_SECONDS = 90 * 24 * 60 * 60;
|
|
4291
|
+
var AUTH_CODE_TTL_SECONDS = 10 * 60;
|
|
4292
|
+
var DEVICE_CODE_TTL_SECONDS = 15 * 60;
|
|
4293
|
+
var INVITE_DEFAULT_TTL_SECONDS = 24 * 60 * 60;
|
|
4294
|
+
var GRANT_INVITE = "urn:relay:invite";
|
|
4295
|
+
var OAuthError = class extends Error {
|
|
4296
|
+
constructor(code, description, status = 400) {
|
|
4297
|
+
super(`${code}: ${description}`);
|
|
4298
|
+
this.code = code;
|
|
4299
|
+
this.description = description;
|
|
4300
|
+
this.status = status;
|
|
4301
|
+
this.name = "OAuthError";
|
|
4302
|
+
}
|
|
4303
|
+
code;
|
|
4304
|
+
description;
|
|
4305
|
+
status;
|
|
4306
|
+
toJSON() {
|
|
4307
|
+
return { error: this.code, error_description: this.description };
|
|
4308
|
+
}
|
|
4309
|
+
};
|
|
4310
|
+
var nowMs = (o) => (o.now ?? Date.now)();
|
|
4311
|
+
var after = (o, seconds) => new Date(nowMs(o) + seconds * 1e3);
|
|
4312
|
+
var isPast = (o, iso2) => new Date(iso2).getTime() <= nowMs(o);
|
|
4313
|
+
function resolveScopes(raw, client, fallback) {
|
|
4314
|
+
const requested = parseScopeParam(raw);
|
|
4315
|
+
if (!requested.length && !fallback) throw new OAuthError("invalid_scope", `scope is required: one or more of ${TOKEN_SCOPES.join(", ")}, space-separated.`);
|
|
4316
|
+
const list = requested.length ? requested : [...fallback];
|
|
4317
|
+
const bad = list.filter((s) => !isTokenScope(s));
|
|
4318
|
+
if (bad.length) throw new OAuthError("invalid_scope", `Unknown scope(s): ${bad.join(", ")}. Valid: ${TOKEN_SCOPES.join(", ")}.`);
|
|
4319
|
+
if (client?.scopes.length) {
|
|
4320
|
+
const over = list.filter((s) => !client.scopes.includes(s));
|
|
4321
|
+
if (over.length) throw new OAuthError("invalid_scope", `This client may not request ${over.join(", ")}.`);
|
|
4322
|
+
}
|
|
4323
|
+
return list;
|
|
4324
|
+
}
|
|
4325
|
+
async function emit(db, o, ev) {
|
|
4326
|
+
const row = await writeEvent(db, { ...ev, project: null }).catch(() => void 0);
|
|
4327
|
+
if (row && o?.onEvent) await Promise.resolve(o.onEvent(row)).catch(() => {
|
|
4328
|
+
});
|
|
4329
|
+
}
|
|
4330
|
+
var APPROVER_SCOPE = "tokens:write";
|
|
4331
|
+
var isHumanActor = (actor) => actor.startsWith("human:");
|
|
4332
|
+
function grantCeilingError(actor, requested, held, o, what = "approve") {
|
|
4333
|
+
if (isHumanActor(actor)) return void 0;
|
|
4334
|
+
const have = new Set(held ?? []);
|
|
4335
|
+
const over = requested.filter((s) => s === APPROVER_SCOPE || !have.has(s));
|
|
4336
|
+
if (!over.length) return void 0;
|
|
4337
|
+
const site = o.issuer.replace(/\/+$/, "");
|
|
4338
|
+
const text2 = what === "approve" ? `a bot can approve only scopes it holds itself, and never tokens:write; ask a person to approve this one at ${site}/activate` : what === "invite" ? `a bot can mint invites only for scopes it holds itself, and never tokens:write; ask a person to create this one at ${site}/tokens` : `a bot can mint tokens only for scopes it holds itself, and never tokens:write; ask a person to create this one at ${site}/tokens`;
|
|
4339
|
+
return new OAuthError("invalid_scope", `${text2} (over the ceiling: ${over.join(", ")}).`, 403);
|
|
4340
|
+
}
|
|
4341
|
+
async function decideDeviceRequest(db, typed, decision, actor, o, approverScopes) {
|
|
4342
|
+
const code = normalizeUserCode(typed);
|
|
4343
|
+
if (!code) throw new OAuthError("invalid_request", "That does not look like a code (RLAY-XXXX).");
|
|
4344
|
+
const current = await getDeviceByUserCode(db, code);
|
|
4345
|
+
if (!current) throw new OAuthError("invalid_grant", "Unknown code.");
|
|
4346
|
+
if (current.status !== "pending") throw new OAuthError("invalid_grant", `That request was already ${current.status}.`);
|
|
4347
|
+
if (isPast(o, current.expires_at)) throw new OAuthError("expired_token", "That request expired; ask the device to start again.");
|
|
4348
|
+
if (decision === "approve") {
|
|
4349
|
+
const ceiling = grantCeilingError(actor, current.scopes, approverScopes, o, "approve");
|
|
4350
|
+
if (ceiling) throw ceiling;
|
|
4351
|
+
}
|
|
4352
|
+
const row = await decideDevice(db, code, { status: decision === "approve" ? "approved" : "denied", actor, now: new Date(nowMs(o)) });
|
|
4353
|
+
if (!row) throw new OAuthError("invalid_grant", "That request was decided by someone else.");
|
|
4354
|
+
await emit(db, o, {
|
|
4355
|
+
kind: decision === "approve" ? "access.granted" : "access.denied",
|
|
4356
|
+
actor,
|
|
4357
|
+
summary: `${actor.replace(/^human:/, "")} ${decision === "approve" ? "approved" : "denied"} ${row.user_code} for ${row.client_name}`,
|
|
4358
|
+
payload: { user_code: row.user_code, client_id: row.client_id, client_name: row.client_name, scopes: row.scopes, decision },
|
|
4359
|
+
dedupe_key: `access:${row.user_code}:${decision}`
|
|
4360
|
+
});
|
|
4361
|
+
return row;
|
|
4362
|
+
}
|
|
4363
|
+
var RECENT_ACCESS_WINDOW_SECONDS = 24 * 60 * 60;
|
|
4364
|
+
async function listRecentAccess(db, o, sinceSeconds = RECENT_ACCESS_WINDOW_SECONDS) {
|
|
4365
|
+
const now = o ? new Date(nowMs(o)) : /* @__PURE__ */ new Date();
|
|
4366
|
+
const rows = await listRecentDevices(db, { since: new Date(now.getTime() - sinceSeconds * 1e3), now });
|
|
4367
|
+
const rank = { pending: 0, approved: 1, redeemed: 2, denied: 2, expired: 2 };
|
|
4368
|
+
return rows.map((d) => ({ ...d, state: (d.status === "pending" || d.status === "approved") && new Date(d.expires_at).getTime() <= now.getTime() ? "expired" : d.status })).sort((a, b) => rank[a.state] - rank[b.state] || b.created_at.localeCompare(a.created_at));
|
|
4369
|
+
}
|
|
4370
|
+
async function createInvite(db, input, o) {
|
|
4371
|
+
const name = input.name.trim();
|
|
4372
|
+
if (!name) throw new OAuthError("invalid_request", "name is required.");
|
|
4373
|
+
if (name.length > 80) throw new OAuthError("invalid_request", "name must be at most 80 characters.");
|
|
4374
|
+
const scopes = resolveScopes(input.scopes, null, []);
|
|
4375
|
+
if (!scopes.length) throw new OAuthError("invalid_scope", "An invite needs at least one scope.");
|
|
4376
|
+
const ceiling = grantCeilingError(input.createdBy, scopes, input.creatorScopes, o, "invite");
|
|
4377
|
+
if (ceiling) throw ceiling;
|
|
4378
|
+
const uses = input.uses ?? 1;
|
|
4379
|
+
if (!Number.isInteger(uses) || uses < 1 || uses > 1e3) throw new OAuthError("invalid_request", "uses must be between 1 and 1000.");
|
|
4380
|
+
const ttl = input.expiresInSeconds ?? INVITE_DEFAULT_TTL_SECONDS;
|
|
4381
|
+
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > 365 * 86400) throw new OAuthError("invalid_request", "expires must be between 1 second and 365 days.");
|
|
4382
|
+
const code = opaqueSecret("rin_", 24);
|
|
4383
|
+
const invite = await insertInvite(db, { code, name, scopes, uses, expires_at: after(o, ttl), created_by: input.createdBy });
|
|
4384
|
+
await emit(db, o, { kind: "access.invite_created", actor: input.createdBy, summary: `${input.createdBy.replace(/^human:/, "")} created invite ${invite.id} for ${name} (${scopeString(scopes)}; ${uses} use(s))`, payload: { invite_id: invite.id, name, scopes, uses, expires_at: invite.expires_at } });
|
|
4385
|
+
return { code, invite };
|
|
4386
|
+
}
|
|
4387
|
+
var listInvites2 = (db, opts) => listInvites(db, opts);
|
|
4388
|
+
async function revokeInvite2(db, idOrName, actor, o) {
|
|
4389
|
+
const row = await revokeInvite(db, idOrName);
|
|
4390
|
+
if (row) await emit(db, o, { kind: "access.invite_revoked", actor: actor ?? row.created_by, summary: `Invite ${row.id} for ${row.name} revoked (${row.uses_left} use(s) left)`, payload: { invite_id: row.id, name: row.name, uses_left: row.uses_left } });
|
|
4391
|
+
return row;
|
|
4392
|
+
}
|
|
4393
|
+
|
|
4394
|
+
// ../core/src/lanes/claude.ts
|
|
4395
|
+
import { execFile as execFile2 } from "child_process";
|
|
4396
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
4397
|
+
import { homedir as homedir4, tmpdir } from "os";
|
|
4398
|
+
import { join as join8 } from "path";
|
|
4399
|
+
|
|
4400
|
+
// ../core/src/lanes/types.ts
|
|
4401
|
+
var locks = /* @__PURE__ */ new Map();
|
|
4402
|
+
async function withSubscriptionLock(key, fn) {
|
|
4403
|
+
const prev = locks.get(key) ?? Promise.resolve();
|
|
4404
|
+
let release;
|
|
4405
|
+
const mine = new Promise((r) => release = r);
|
|
4406
|
+
locks.set(key, prev.then(() => mine));
|
|
4407
|
+
await prev;
|
|
4408
|
+
try {
|
|
4409
|
+
return await fn();
|
|
4410
|
+
} finally {
|
|
4411
|
+
release();
|
|
4412
|
+
if (locks.get(key) === mine) locks.delete(key);
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
// ../core/src/lanes/sandbox.ts
|
|
4417
|
+
var SANDBOX_DEFAULT_DOMAINS = ["registry.npmjs.org", "registry.yarnpkg.com", "github.com", "objects.githubusercontent.com", "codeload.github.com", "fonts.googleapis.com", "fonts.gstatic.com", "pypi.org", "files.pythonhosted.org", "crates.io", "static.crates.io", "proxy.golang.org"];
|
|
4418
|
+
var SANDBOX_DENIED_DIRS = ["~/.harness/relay.pglite", "~/.claude", "~/.codex", "~/.grok", "~/.fx", "~/.ssh", "~/.aws", "~/.config/gh"];
|
|
4419
|
+
var SANDBOX_DENIED_FILES = ["~/.harness/credentials.json", "~/.harness/config.json", "~/.harness/work.log", "~/.claude.json", "~/.netrc", "~/.npmrc", "~/.gitconfig"];
|
|
4420
|
+
var SANDBOX_DENIED_PATHS = [...SANDBOX_DENIED_DIRS, ...SANDBOX_DENIED_FILES];
|
|
4421
|
+
var SANDBOX_CACHE_DIRS = ["~/Library/pnpm", "~/Library/Caches/pnpm", "~/.local/share/pnpm", "~/.cache/pnpm", "~/.npm"];
|
|
4422
|
+
var uniq = (xs) => [...new Set([...xs].map((s) => s.trim()).filter(Boolean))];
|
|
4423
|
+
var parseDomainList = (s) => uniq((s ?? "").split(","));
|
|
4424
|
+
var denyRule = (tool, path) => `${tool}(${SANDBOX_DENIED_FILES.includes(path) ? path : `${path}/**`})`;
|
|
4425
|
+
function claudeSandboxSettings(opts = {}) {
|
|
4426
|
+
const denied = [...SANDBOX_DENIED_PATHS];
|
|
4427
|
+
return {
|
|
4428
|
+
sandbox: {
|
|
4429
|
+
enabled: true,
|
|
4430
|
+
autoAllowBashIfSandboxed: true,
|
|
4431
|
+
failIfUnavailable: true,
|
|
4432
|
+
allowUnsandboxedCommands: false,
|
|
4433
|
+
excludedCommands: [],
|
|
4434
|
+
network: {
|
|
4435
|
+
strictAllowlist: true,
|
|
4436
|
+
allowedDomains: uniq([...SANDBOX_DEFAULT_DOMAINS, ...opts.allowedDomains ?? []]),
|
|
4437
|
+
deniedDomains: [],
|
|
4438
|
+
allowLocalBinding: opts.allowLocalBinding ?? true
|
|
4439
|
+
},
|
|
4440
|
+
filesystem: {
|
|
4441
|
+
allowWrite: uniq([...SANDBOX_CACHE_DIRS, ...opts.allowWrite ?? []]),
|
|
4442
|
+
denyRead: denied
|
|
4443
|
+
},
|
|
4444
|
+
credentials: {
|
|
4445
|
+
files: denied.map((path) => ({ path, mode: "deny" }))
|
|
4446
|
+
}
|
|
4447
|
+
},
|
|
4448
|
+
permissions: {
|
|
4449
|
+
blockReadsOutsideWorkingDirectories: true,
|
|
4450
|
+
deny: [...denied.map((p) => denyRule("Read", p)), ...denied.map((p) => denyRule("Edit", p))]
|
|
4451
|
+
}
|
|
4452
|
+
};
|
|
4453
|
+
}
|
|
4454
|
+
var claudeSandboxSettingsJson = (opts = {}) => JSON.stringify(claudeSandboxSettings(opts));
|
|
4455
|
+
var SANDBOX_UNAVAILABLE = "sandbox unavailable";
|
|
4456
|
+
function looksLikeSandboxUnavailable(text2) {
|
|
4457
|
+
const t = text2.toLowerCase();
|
|
4458
|
+
return /sandbox/.test(t) && /(unavailable|not available|not supported|unsupported|could not|cannot|can't|failed to|missing|not found|not installed|bubblewrap|bwrap|socat|seatbelt)/.test(t);
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
// ../core/src/lanes/claude.ts
|
|
4462
|
+
var MODEL_IDS = { fable: "anthropic/claude-fable-5-1", opus: "anthropic/claude-opus-5", sonnet: "anthropic/claude-sonnet-5" };
|
|
4463
|
+
var isUtility = (opts) => opts.tools !== void 0 && opts.tools.length === 0;
|
|
4464
|
+
function sandboxFor(req, opts) {
|
|
4465
|
+
const on = opts.sandbox ?? req.lane === "code";
|
|
4466
|
+
if (on === false) return void 0;
|
|
4467
|
+
const o = on === true ? {} : { ...on };
|
|
4468
|
+
const extra = parseDomainList((req.env ?? process.env)["RELAY_LANE_ALLOWED_DOMAINS"]);
|
|
4469
|
+
if (extra.length) o.allowedDomains = [...o.allowedDomains ?? [], ...extra];
|
|
4470
|
+
return o;
|
|
4471
|
+
}
|
|
4472
|
+
function claudeArgs(req, opts) {
|
|
4473
|
+
const utility = isUtility(opts);
|
|
4474
|
+
const args = ["-p", "--output-format", "json", "--model", opts.model];
|
|
4475
|
+
args.push("--permission-mode", req.readOnly ? "plan" : "acceptEdits");
|
|
4476
|
+
if (opts.tools) args.push("--tools", opts.tools.join(","));
|
|
4477
|
+
if (opts.allowedTools?.length) args.push("--allowedTools", opts.allowedTools.join(","));
|
|
4478
|
+
else if (req.readOnly && opts.tools === void 0) args.push("--allowedTools", "Read,Grep,Glob");
|
|
4479
|
+
if (opts.disallowedTools?.length) args.push("--disallowedTools", opts.disallowedTools.join(","));
|
|
4480
|
+
args.push("--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--setting-sources", "");
|
|
4481
|
+
const sandbox = sandboxFor(req, opts);
|
|
4482
|
+
if (sandbox) args.push("--settings", claudeSandboxSettingsJson(sandbox));
|
|
4483
|
+
if (utility) args.push("--disable-slash-commands");
|
|
4484
|
+
for (const d of req.addDirs ?? []) args.push("--add-dir", d);
|
|
4485
|
+
if (req.system) args.push("--append-system-prompt", req.system);
|
|
4486
|
+
if (req.jsonSchema) args.push("--json-schema", JSON.stringify(req.jsonSchema));
|
|
4487
|
+
if (opts.resume) args.push("--resume", opts.resume);
|
|
4488
|
+
if (opts.maxBudgetUsd) args.push("--max-budget-usd", String(opts.maxBudgetUsd));
|
|
4489
|
+
if (opts.maxTurns) args.push("--max-turns", String(opts.maxTurns));
|
|
4490
|
+
args.push("--", req.prompt);
|
|
4491
|
+
return args;
|
|
4492
|
+
}
|
|
4493
|
+
function fakeClaude(req, opts) {
|
|
4494
|
+
const props = req.jsonSchema?.properties ?? {};
|
|
4495
|
+
let json2;
|
|
4496
|
+
let text2;
|
|
4497
|
+
if ("files" in props) {
|
|
4498
|
+
const paths = [...req.prompt.matchAll(/^=== FILE: (.+) ===$/gm)].map((m) => m[1]);
|
|
4499
|
+
json2 = { files: paths.map((path) => ({ path, summary: `Canned digest of ${path}: a file of the test repository, summarized here for the index with enough words to pass validation.` })) };
|
|
4500
|
+
text2 = JSON.stringify(json2);
|
|
4501
|
+
} else if ("summary" in props) {
|
|
4502
|
+
const para = (n) => Array.from({ length: n }, (_, i) => `Sentence ${i + 1} of this canned section describes the project in enough detail to count as real text.`).join(" ");
|
|
4503
|
+
json2 = { summary: `## Purpose
|
|
4504
|
+
|
|
4505
|
+
${para(4)}
|
|
4506
|
+
|
|
4507
|
+
## Stack and layout
|
|
4508
|
+
|
|
4509
|
+
${para(5)}
|
|
4510
|
+
|
|
4511
|
+
## Commands
|
|
4512
|
+
|
|
4513
|
+
${para(3)}
|
|
4514
|
+
|
|
4515
|
+
## Conventions
|
|
4516
|
+
|
|
4517
|
+
${para(4)}
|
|
4518
|
+
|
|
4519
|
+
## Gotchas
|
|
4520
|
+
|
|
4521
|
+
${para(3)}
|
|
4522
|
+
` };
|
|
4523
|
+
text2 = JSON.stringify(json2);
|
|
4524
|
+
} else if ("verdict" in props) {
|
|
4525
|
+
json2 = { verdict: "clear", score: 0, reasons: [] };
|
|
4526
|
+
text2 = JSON.stringify(json2);
|
|
4527
|
+
} else text2 = "pong";
|
|
4528
|
+
const model = MODEL_IDS[opts.model];
|
|
4529
|
+
const tokensIn = approxTokens(req.prompt + (req.system ?? ""));
|
|
4530
|
+
const tokensOut = approxTokens(text2);
|
|
4531
|
+
const res = {
|
|
4532
|
+
ok: true,
|
|
4533
|
+
text: text2,
|
|
4534
|
+
model,
|
|
4535
|
+
subscription: "max",
|
|
4536
|
+
tokensIn,
|
|
4537
|
+
tokensOut,
|
|
4538
|
+
cacheRead: 0,
|
|
4539
|
+
cacheWrite: 0,
|
|
4540
|
+
costEquiv: costEquiv({ lane: req.lane, model, provider: "anthropic", subscription: "max", tokens_in: tokensIn, tokens_out: tokensOut, duration_ms: 0 }),
|
|
4541
|
+
durationMs: 0,
|
|
4542
|
+
raw: { fake: true }
|
|
4543
|
+
};
|
|
4544
|
+
if (json2 !== void 0) res.json = json2;
|
|
4545
|
+
return res;
|
|
4546
|
+
}
|
|
4547
|
+
async function runClaude(req, opts) {
|
|
4548
|
+
if (process.env.RELAY_FAKE_LANES === "1") return fakeClaude(req, opts);
|
|
4549
|
+
const bin = opts.bin ?? process.env.RELAY_CLAUDE_BIN ?? join8(homedir4(), ".local", "bin", "claude");
|
|
4550
|
+
const model = MODEL_IDS[opts.model];
|
|
4551
|
+
const utility = isUtility(opts);
|
|
4552
|
+
return withSubscriptionLock("claude:max", async () => {
|
|
4553
|
+
const args = claudeArgs(req, opts);
|
|
4554
|
+
const t0 = Date.now();
|
|
4555
|
+
const env = { ...req.env ?? process.env };
|
|
4556
|
+
delete env["CLAUDECODE"];
|
|
4557
|
+
delete env["CLAUDE_CODE_ENTRYPOINT"];
|
|
4558
|
+
delete env["ANTHROPIC_API_KEY"];
|
|
4559
|
+
delete env["ANTHROPIC_AUTH_TOKEN"];
|
|
4560
|
+
let cwd = req.cwd;
|
|
4561
|
+
let scratch;
|
|
4562
|
+
if (utility) {
|
|
4563
|
+
env["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
|
|
4564
|
+
env["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] = "1";
|
|
4565
|
+
scratch = mkdtempSync(join8(tmpdir(), "relay-lane-"));
|
|
4566
|
+
cwd = scratch;
|
|
4567
|
+
}
|
|
4568
|
+
const { stdout, stderr, code } = await new Promise((resolve2) => {
|
|
4569
|
+
const child = execFile2(
|
|
4570
|
+
bin,
|
|
4571
|
+
args,
|
|
4572
|
+
{ cwd, env, maxBuffer: 64 * 1024 * 1024, timeout: req.timeoutMs ?? 20 * 6e4 },
|
|
4573
|
+
(err, stdout2, stderr2) => resolve2({ stdout: String(stdout2), stderr: String(stderr2), code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0 })
|
|
4574
|
+
);
|
|
4575
|
+
child.stdin?.end();
|
|
4576
|
+
});
|
|
4577
|
+
if (scratch) rmSync(scratch, { recursive: true, force: true });
|
|
4578
|
+
const durationMs = Date.now() - t0;
|
|
4579
|
+
let j;
|
|
4580
|
+
try {
|
|
4581
|
+
j = JSON.parse(stdout.trim());
|
|
4582
|
+
} catch {
|
|
4583
|
+
}
|
|
4584
|
+
const u = j?.usage ?? {};
|
|
4585
|
+
const tokensIn = u.input_tokens ?? 0;
|
|
4586
|
+
const tokensOut = u.output_tokens ?? 0;
|
|
4587
|
+
const cacheRead = u.cache_read_input_tokens ?? 0;
|
|
4588
|
+
const cacheWrite = u.cache_creation_input_tokens ?? 0;
|
|
4589
|
+
const ok = !!j && !j.is_error && code === 0;
|
|
4590
|
+
const res = {
|
|
4591
|
+
ok,
|
|
4592
|
+
text: j?.result ?? "",
|
|
4593
|
+
model,
|
|
4594
|
+
subscription: "max",
|
|
4595
|
+
tokensIn,
|
|
4596
|
+
tokensOut,
|
|
4597
|
+
cacheRead,
|
|
4598
|
+
cacheWrite,
|
|
4599
|
+
costEquiv: costEquiv({ lane: req.lane, model, provider: "anthropic", subscription: "max", tokens_in: tokensIn, tokens_out: tokensOut, cache_read: cacheRead, cache_write: cacheWrite, duration_ms: durationMs }),
|
|
4600
|
+
durationMs,
|
|
4601
|
+
raw: j ?? { stdout: stdout.slice(0, 2e3), stderr: stderr.slice(0, 2e3), code }
|
|
4602
|
+
};
|
|
4603
|
+
if (j?.structured_output !== void 0) res.json = j.structured_output;
|
|
4604
|
+
if (j?.session_id) res.sessionId = j.session_id;
|
|
4605
|
+
if (!ok) {
|
|
4606
|
+
const msg = j?.result || stderr || `claude exited ${code}`;
|
|
4607
|
+
const unavailable = sandboxFor(req, opts) !== void 0 && !j && looksLikeSandboxUnavailable(`${stderr}
|
|
4608
|
+
${stdout}`);
|
|
4609
|
+
res.error = (unavailable ? `${SANDBOX_UNAVAILABLE}: ${msg}` : msg).slice(0, 500);
|
|
4610
|
+
}
|
|
4611
|
+
return res;
|
|
4612
|
+
});
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
// ../core/src/tasks/gate.ts
|
|
4616
|
+
var GATE_TEXT_CAP = 20 * 1024;
|
|
4617
|
+
var GATE_MARKERS = [
|
|
4618
|
+
{ name: "ignore previous instructions", re: /\b(?:ignore|disregard|forget|override)\b[^\n.]{0,40}\b(?:previous|prior|above|earlier|all|your)\b[^\n.]{0,24}\b(?:instructions?|rules|guidance|guidelines|prompt|constraints)\b/i },
|
|
4619
|
+
{ name: "curl \u2026 | sh", re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/i },
|
|
4620
|
+
{ name: ".ssh", re: /(?:^|[\s"'`(=:/~])\.ssh(?:\/|\b)/ },
|
|
4621
|
+
{ name: "credentials.json", re: /\bcredentials\.json\b/i },
|
|
4622
|
+
{ name: "GITHUB_TOKEN", re: /\b(?:GITHUB_TOKEN|GH_TOKEN)\b/ },
|
|
4623
|
+
{ name: "ANTHROPIC_API_KEY", re: /\b(?:ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|OPENAI_API_KEY|XAI_API_KEY)\b/ },
|
|
4624
|
+
{ name: "--dangerously-skip-permissions", re: /--dangerously-skip-permissions\b/ },
|
|
4625
|
+
{ name: "git push --force to main", re: /git\s+push\b[^\n]*(?:--force(?:-with-lease)?|\s-f\b)[^\n]*\b(?:main|master)\b|git\s+push\b[^\n]*\b(?:main|master)\b[^\n]*(?:--force(?:-with-lease)?|\s-f\b)/i },
|
|
4626
|
+
{ name: "~/.harness", re: /~\/\.harness\b|\$\{?RELAY_HOME\b|\.harness\/(?:credentials|relay\.pglite|work)\b/ }
|
|
4627
|
+
];
|
|
4628
|
+
function precheckTask(text2) {
|
|
4629
|
+
const hits = [];
|
|
4630
|
+
for (const m of GATE_MARKERS) if (m.re.test(text2)) hits.push(m.name);
|
|
4631
|
+
return hits;
|
|
4632
|
+
}
|
|
4633
|
+
var GATE_TIMEOUT_MS = 3 * 6e4;
|
|
4634
|
+
function heldEvent(task, actor, v, extra = {}) {
|
|
4635
|
+
const first = v.reasons.find((r) => r.trim()) ?? "flagged by the gate";
|
|
4636
|
+
return {
|
|
4637
|
+
kind: "task.held",
|
|
4638
|
+
actor,
|
|
4639
|
+
project: task.project,
|
|
4640
|
+
repo: task.repo,
|
|
4641
|
+
issue: task.issue,
|
|
4642
|
+
lane: extra.lane ?? task.lane,
|
|
4643
|
+
url: `https://github.com/${task.repo}/issues/${task.issue}`,
|
|
4644
|
+
summary: `Held #${task.issue} at the gate (${v.grader}, ${v.score.toFixed(2)}): ${first}`.slice(0, 500),
|
|
4645
|
+
payload: { score: v.score, reasons: v.reasons, grader: v.grader, model: v.model ?? null, ...extra.payload ?? {} },
|
|
4646
|
+
...extra.dedupe_key !== void 0 ? { dedupe_key: extra.dedupe_key } : {}
|
|
4647
|
+
};
|
|
4648
|
+
}
|
|
4649
|
+
function heldInfo(events) {
|
|
4650
|
+
let best;
|
|
4651
|
+
for (const e of events) if (e.kind === "task.held" && (!best || e.ts > best.ts || e.ts === best.ts && e.id > best.id)) best = e;
|
|
4652
|
+
if (!best) return void 0;
|
|
4653
|
+
const p = best.payload;
|
|
4654
|
+
return {
|
|
4655
|
+
score: typeof p.score === "number" ? p.score : 1,
|
|
4656
|
+
reasons: Array.isArray(p.reasons) ? p.reasons.map((r) => String(r)) : [],
|
|
4657
|
+
grader: typeof p.grader === "string" ? p.grader : "unknown",
|
|
4658
|
+
model: typeof p.model === "string" ? p.model : null,
|
|
4659
|
+
by: best.actor,
|
|
4660
|
+
at: best.ts
|
|
4661
|
+
};
|
|
4662
|
+
}
|
|
4663
|
+
var TaskGateError = class extends Error {
|
|
4664
|
+
constructor(message2, status = 400) {
|
|
4665
|
+
super(message2);
|
|
3505
4666
|
this.status = status;
|
|
3506
|
-
this.name = "
|
|
4667
|
+
this.name = "TaskGateError";
|
|
3507
4668
|
}
|
|
3508
|
-
code;
|
|
3509
|
-
description;
|
|
3510
4669
|
status;
|
|
3511
|
-
toJSON() {
|
|
3512
|
-
return { error: this.code, error_description: this.description };
|
|
3513
|
-
}
|
|
3514
4670
|
};
|
|
3515
|
-
var
|
|
3516
|
-
var
|
|
3517
|
-
function
|
|
3518
|
-
const
|
|
3519
|
-
|
|
3520
|
-
const
|
|
3521
|
-
if (
|
|
3522
|
-
if (
|
|
3523
|
-
|
|
3524
|
-
if (
|
|
4671
|
+
var RELEASERS_TEXT = "a signed-in person, or a bot whose token holds tokens:write and that did not open the task itself";
|
|
4672
|
+
var nameOf2 = (actor) => actor.slice(actor.indexOf(":") + 1).replace(/^oauth:/, "").toLowerCase();
|
|
4673
|
+
async function releaseTask(db, taskId2, actor, opts = {}) {
|
|
4674
|
+
const task = await getTask(db, taskId2.trim());
|
|
4675
|
+
if (!task) throw new TaskGateError(`No task ${taskId2}.`, 404);
|
|
4676
|
+
const note = opts.note?.trim() || null;
|
|
4677
|
+
if (note && note.length > 2e3) throw new TaskGateError("The note must be at most 2000 characters.", 400);
|
|
4678
|
+
if (!actor.startsWith("human:")) {
|
|
4679
|
+
if (opts.scopes && !opts.scopes.includes("tokens:write")) throw new TaskGateError(`${actor} cannot release ${task.id}: its token lacks tokens:write. Who can: ${RELEASERS_TEXT}.`, 403);
|
|
4680
|
+
if (nameOf2(task.created_by) === nameOf2(actor)) throw new TaskGateError(`${actor} cannot release ${task.id}: it opened this task. A bot never releases its own hold; who can: ${RELEASERS_TEXT}.`, 403);
|
|
3525
4681
|
}
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
const
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
4682
|
+
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
4683
|
+
const status = deriveTaskStatus(task, events, claims);
|
|
4684
|
+
if (status.state !== "held") throw new TaskGateError(`${task.id} is ${status.state}, not held; only a held task can be released.`, 409);
|
|
4685
|
+
const held = heldInfo(events);
|
|
4686
|
+
const written = [];
|
|
4687
|
+
const row = await writeEvent(db, {
|
|
4688
|
+
kind: "task.released",
|
|
4689
|
+
actor,
|
|
4690
|
+
project: task.project,
|
|
4691
|
+
repo: task.repo,
|
|
4692
|
+
issue: task.issue,
|
|
4693
|
+
lane: task.lane ?? status.lane,
|
|
4694
|
+
pr: status.pr,
|
|
4695
|
+
url: status.issue_url,
|
|
4696
|
+
summary: `Released #${task.issue} from the gate${note ? `: ${note.split("\n").find((l) => l.trim()) ?? ""}` : ""}`.slice(0, 500),
|
|
4697
|
+
payload: { note, held: held ?? null, previous_state: "held" }
|
|
4698
|
+
});
|
|
4699
|
+
if (row) written.push(row);
|
|
4700
|
+
await bumpQueueVersion(db);
|
|
4701
|
+
return { task: await taskView(db, task), events: written };
|
|
3541
4702
|
}
|
|
3542
|
-
var listInvites2 = (db, opts) => listInvites(db, opts);
|
|
3543
|
-
var revokeInvite2 = (db, idOrName) => revokeInvite(db, idOrName);
|
|
3544
4703
|
|
|
3545
4704
|
// ../core/src/tasks/create.ts
|
|
3546
4705
|
var WORKER_LANES = ["claude", "codex", "grok"];
|
|
@@ -3592,17 +4751,112 @@ ${marker}
|
|
|
3592
4751
|
const missing = labels.filter((l) => !issue.labels.includes(l));
|
|
3593
4752
|
if (missing.length) await github.addLabels(project.repo, issue.number, missing).catch(() => {
|
|
3594
4753
|
});
|
|
3595
|
-
const { task, created } = await createTask(db, { project: project.slug, repo: project.repo, issue: issue.number, idempotencyKey: key, createdBy: input.actor, lane: input.lane });
|
|
4754
|
+
const { task, created } = await createTask(db, { project: project.slug, repo: project.repo, issue: issue.number, idempotencyKey: key, createdBy: input.actor, lane: input.lane, title: input.title.trim() });
|
|
3596
4755
|
const events = [];
|
|
3597
4756
|
const base = { actor: input.actor, project: project.slug, repo: project.repo, issue: issue.number, lane: input.lane, url: issue.html_url };
|
|
3598
4757
|
const opened = await writeEvent(db, { ...base, kind: "issue.opened", summary: input.title.trim(), payload: { labels, via: "relay_task_create" }, dedupe_key: `task:${task.id}:opened` });
|
|
3599
4758
|
if (opened) events.push(opened);
|
|
3600
4759
|
const createdEv = await writeEvent(db, { ...base, kind: "task.created", summary: `${task.id}: ${input.title.trim()}`, payload: { task_id: task.id, lane: input.lane, priority: input.priority ?? null, idempotency_key: key }, dedupe_key: `task:${task.id}:created` });
|
|
3601
4760
|
if (createdEv) events.push(createdEv);
|
|
4761
|
+
const hits = precheckTask(`${input.title}
|
|
4762
|
+
|
|
4763
|
+
${input.body ?? ""}`);
|
|
4764
|
+
if (hits.length) {
|
|
4765
|
+
const held = await writeEvent(db, heldEvent({ ...task, lane: input.lane }, "bot:relay", { score: 1, reasons: hits.map((h) => `pre-check: ${h}`), grader: "precheck", model: null }, { payload: { opened_by: input.actor }, dedupe_key: `task:${task.id}:held:create` }));
|
|
4766
|
+
if (held) events.push(held);
|
|
4767
|
+
}
|
|
3602
4768
|
const view = await taskView(db, task);
|
|
3603
4769
|
return { task: view, task_id: view.id, issue_url: issue.html_url || view.issue_url, created, events };
|
|
3604
4770
|
}
|
|
3605
4771
|
|
|
4772
|
+
// ../core/src/tasks/requeue.ts
|
|
4773
|
+
var TaskRequeueError = class extends Error {
|
|
4774
|
+
constructor(message2, status = 400) {
|
|
4775
|
+
super(message2);
|
|
4776
|
+
this.status = status;
|
|
4777
|
+
this.name = "TaskRequeueError";
|
|
4778
|
+
}
|
|
4779
|
+
status;
|
|
4780
|
+
};
|
|
4781
|
+
var REQUEUERS_TEXT = "a signed-in person, or a bot whose token holds tasks:write";
|
|
4782
|
+
var laneLabel = (lane) => `agent:${lane}`;
|
|
4783
|
+
var describe2 = (e) => (e instanceof Error ? e.message : String(e)).slice(0, 300);
|
|
4784
|
+
async function relabel(github, repo, issue, lane) {
|
|
4785
|
+
if (!github) return { relabelled: false, error: "No GitHub credential: set GITHUB_TOKEN (web) or log in with gh (CLI); move the agent:* label by hand." };
|
|
4786
|
+
try {
|
|
4787
|
+
const current = await github.getIssue(repo, issue);
|
|
4788
|
+
const want = laneLabel(lane);
|
|
4789
|
+
const removed = [];
|
|
4790
|
+
for (const l of current.labels) {
|
|
4791
|
+
if (l.startsWith("agent:") && l !== want) {
|
|
4792
|
+
await github.removeLabel(repo, issue, l);
|
|
4793
|
+
removed.push(l);
|
|
4794
|
+
}
|
|
4795
|
+
}
|
|
4796
|
+
let added = null;
|
|
4797
|
+
if (!current.labels.includes(want)) {
|
|
4798
|
+
await github.addLabels(repo, issue, [want]);
|
|
4799
|
+
added = want;
|
|
4800
|
+
}
|
|
4801
|
+
return { relabelled: true, removed, added };
|
|
4802
|
+
} catch (e) {
|
|
4803
|
+
return { relabelled: false, error: describe2(e) };
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
async function assertRequeueable(db, taskId2, actor, opts) {
|
|
4807
|
+
const task = await getTask(db, taskId2.trim());
|
|
4808
|
+
if (!task) throw new TaskRequeueError(`No task ${taskId2}.`, 404);
|
|
4809
|
+
if (opts.lane != null && opts.lane !== "" && !isTaskLane(opts.lane)) throw new TaskRequeueError(`lane must be one of ${TASK_LANES.join(", ")}.`, 400);
|
|
4810
|
+
if (opts.reason && opts.reason.length > 2e3) throw new TaskRequeueError("The reason must be at most 2000 characters.", 400);
|
|
4811
|
+
if (!actor.startsWith("human:") && opts.scopes && !opts.scopes.includes("tasks:write")) {
|
|
4812
|
+
throw new TaskRequeueError(`${actor} cannot requeue ${task.id}: its token lacks tasks:write. Who can: ${REQUEUERS_TEXT}.`, 403);
|
|
4813
|
+
}
|
|
4814
|
+
const [events, claims] = await Promise.all([taskEvents(db, task), taskClaims(db, task.id)]);
|
|
4815
|
+
const status = deriveTaskStatus(task, events, claims);
|
|
4816
|
+
if (status.state === "done") throw new TaskRequeueError(`${task.id} is done (merged or closed); reopen the issue on GitHub to start it again.`, 409);
|
|
4817
|
+
return { task, status };
|
|
4818
|
+
}
|
|
4819
|
+
async function requeueTask(db, github, taskId2, actor, opts = {}) {
|
|
4820
|
+
const { task, status } = await assertRequeueable(db, taskId2, actor, opts);
|
|
4821
|
+
const previousLane = task.lane ?? status.lane;
|
|
4822
|
+
const lane = opts.lane ? opts.lane : previousLane;
|
|
4823
|
+
const relaned = Boolean(lane && lane !== previousLane);
|
|
4824
|
+
if (relaned) await db.query("UPDATE tasks SET lane = $2 WHERE id = $1", [task.id, lane]);
|
|
4825
|
+
const github_result = lane ? await relabel(github, task.repo, task.issue, lane) : null;
|
|
4826
|
+
let released = null;
|
|
4827
|
+
const claim = await liveClaim(db, task.id);
|
|
4828
|
+
if (claim) {
|
|
4829
|
+
const r = await releaseClaim(db, task.id, claim.worker_id, "released");
|
|
4830
|
+
if (r) released = (await getWorker(db, claim.worker_id))?.name ?? claim.worker_id;
|
|
4831
|
+
}
|
|
4832
|
+
const reason = opts.reason?.trim() || null;
|
|
4833
|
+
const events = [];
|
|
4834
|
+
const detail = [relaned ? `on the ${lane} lane (was ${previousLane ?? "no lane"})` : lane ? `on the ${lane} lane` : "", `was ${status.state}`].filter(Boolean).join(", ");
|
|
4835
|
+
const row = await writeEvent(db, {
|
|
4836
|
+
actor,
|
|
4837
|
+
project: task.project,
|
|
4838
|
+
repo: task.repo,
|
|
4839
|
+
issue: task.issue,
|
|
4840
|
+
lane,
|
|
4841
|
+
pr: status.pr,
|
|
4842
|
+
kind: "task.requeued",
|
|
4843
|
+
summary: `Requeued #${task.issue} ${detail}${reason ? `: ${reason.split("\n").find((l) => l.trim()) ?? ""}` : ""}`.slice(0, 500),
|
|
4844
|
+
url: status.pr_url ?? status.issue_url,
|
|
4845
|
+
payload: {
|
|
4846
|
+
previous_state: status.state,
|
|
4847
|
+
previous_lane: previousLane,
|
|
4848
|
+
lane,
|
|
4849
|
+
reason,
|
|
4850
|
+
github: github_result,
|
|
4851
|
+
claim_released: released,
|
|
4852
|
+
branch: status.branch
|
|
4853
|
+
}
|
|
4854
|
+
});
|
|
4855
|
+
if (row) events.push(row);
|
|
4856
|
+
await bumpQueueVersion(db);
|
|
4857
|
+
return { task: await taskView(db, await getTask(db, task.id) ?? task), events };
|
|
4858
|
+
}
|
|
4859
|
+
|
|
3606
4860
|
// ../core/src/webhooks/dispatch.ts
|
|
3607
4861
|
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3608
4862
|
var SIGNATURE_HEADER = "x-relay-signature";
|
|
@@ -3684,6 +4938,7 @@ function pingPayload(hook, deliveryId = ulid()) {
|
|
|
3684
4938
|
}
|
|
3685
4939
|
|
|
3686
4940
|
// ../core/src/client/local.ts
|
|
4941
|
+
var GITHUB_INSTALLATIONS_URL = "https://github.com/settings/installations";
|
|
3687
4942
|
var EXTERNAL_KIND = /^external\.[a-z0-9_.-]+$/i;
|
|
3688
4943
|
function localClient(db, opts) {
|
|
3689
4944
|
const github = () => typeof opts.github === "function" ? opts.github() : opts.github ?? detectGitHub();
|
|
@@ -3728,6 +4983,16 @@ function localClient(db, opts) {
|
|
|
3728
4983
|
await setProjectSummary(db, slug, summary, tokens);
|
|
3729
4984
|
await recordUsage(db, slug, o.usage);
|
|
3730
4985
|
},
|
|
4986
|
+
async githubStatus(slug) {
|
|
4987
|
+
const p = await getProject(db, slug);
|
|
4988
|
+
if (!p) return void 0;
|
|
4989
|
+
const stats = p.repo ? await githubDeliveryStats(db, p.repo) : { count: 0, last_delivery_at: null };
|
|
4990
|
+
return {
|
|
4991
|
+
repo: p.repo ?? null,
|
|
4992
|
+
app: { configured: false, slug: null, installed: false, installation_id: null, install_url: opts.installUrl ?? GITHUB_INSTALLATIONS_URL },
|
|
4993
|
+
webhook: { org_or_repo_hook_seen: stats.count > 0, last_delivery_at: stats.last_delivery_at }
|
|
4994
|
+
};
|
|
4995
|
+
},
|
|
3731
4996
|
async recall(q) {
|
|
3732
4997
|
if (q.project) {
|
|
3733
4998
|
const preferences2 = await mergedPreferences(db, { project: q.project, ...q.lane ? { lane: q.lane } : {} });
|
|
@@ -3776,12 +5041,32 @@ function localClient(db, opts) {
|
|
|
3776
5041
|
return t ? taskView(db, t) : void 0;
|
|
3777
5042
|
},
|
|
3778
5043
|
listTasks: (o = {}) => listTaskViews(db, o),
|
|
5044
|
+
async approveTask(id) {
|
|
5045
|
+
const r = await approveTask(db, github(), id, opts.actor);
|
|
5046
|
+
for (const ev of r.events) await afterWrite(ev);
|
|
5047
|
+
return r.task;
|
|
5048
|
+
},
|
|
5049
|
+
async requestChanges(id, note) {
|
|
5050
|
+
const r = await requestChanges(db, github(), id, opts.actor, note);
|
|
5051
|
+
for (const ev of r.events) await afterWrite(ev);
|
|
5052
|
+
return r.task;
|
|
5053
|
+
},
|
|
5054
|
+
async requeueTask(id, o = {}) {
|
|
5055
|
+
const r = await requeueTask(db, github(), id, opts.actor, { lane: o.lane ?? null, reason: o.reason ?? null });
|
|
5056
|
+
for (const ev of r.events) await afterWrite(ev);
|
|
5057
|
+
return r.task;
|
|
5058
|
+
},
|
|
5059
|
+
async releaseTask(id, o = {}) {
|
|
5060
|
+
const r = await releaseTask(db, id, opts.actor, { note: o.note ?? null });
|
|
5061
|
+
for (const ev of r.events) await afterWrite(ev);
|
|
5062
|
+
return r.task;
|
|
5063
|
+
},
|
|
3779
5064
|
async createInvite(i) {
|
|
3780
5065
|
const r = await createInvite(db, { name: i.name, scopes: i.scopes, ...i.expiresInSeconds ? { expiresInSeconds: i.expiresInSeconds } : {}, ...i.uses ? { uses: i.uses } : {}, createdBy: opts.actor }, { issuer: opts.issuer ?? "relay://local" });
|
|
3781
5066
|
return r;
|
|
3782
5067
|
},
|
|
3783
5068
|
listInvites: (o = {}) => listInvites2(db, o),
|
|
3784
|
-
revokeInvite: (idOrName) => revokeInvite2(db, idOrName),
|
|
5069
|
+
revokeInvite: (idOrName) => revokeInvite2(db, idOrName, opts.actor),
|
|
3785
5070
|
whoami: async () => ({ actor: opts.actor, name: opts.actor, scopes: [...TOKEN_SCOPES], expires_at: null, source: "local" }),
|
|
3786
5071
|
async addWebhook(input) {
|
|
3787
5072
|
const row = await createWebhook(db, { project: input.project ?? null, url: input.url, events: input.events, bearer: input.bearer ?? null, ...input.secret ? { secret: input.secret } : {}, createdBy: opts.actor });
|
|
@@ -3870,7 +5155,7 @@ async function openHarness(cfg = loadConfig(), opts = {}) {
|
|
|
3870
5155
|
const siteMode = !!cfg.site && !!cfg.siteToken;
|
|
3871
5156
|
const db = siteMode && !opts.local ? siteModeDb(cfg.site) : await openDb(cfg.databaseUrl ? { url: cfg.databaseUrl } : { file: cfg.dbFile });
|
|
3872
5157
|
const ledger = new Ledger(db);
|
|
3873
|
-
const client = siteMode ? httpClient({ site: cfg.site, token: cfg.siteToken, actor }) : localClient(db, { actor, embed: briefEmbedder({ cfg, ledger }) });
|
|
5158
|
+
const client = siteMode ? httpClient({ site: cfg.site, token: cfg.siteToken, actor }) : localClient(db, { actor, embed: briefEmbedder({ cfg, ledger }), ...cfg.site ? { installUrl: `${cfg.site}/setup/github` } : {} });
|
|
3874
5159
|
return {
|
|
3875
5160
|
cfg,
|
|
3876
5161
|
db,
|
|
@@ -3886,35 +5171,319 @@ async function openHarness(cfg = loadConfig(), opts = {}) {
|
|
|
3886
5171
|
|
|
3887
5172
|
// ../core/src/work/job.ts
|
|
3888
5173
|
import { execFile as execFile6 } from "child_process";
|
|
3889
|
-
import { existsSync as
|
|
5174
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, rmSync as rmSync2 } from "fs";
|
|
3890
5175
|
import { homedir as homedir6 } from "os";
|
|
3891
|
-
import { join as
|
|
3892
|
-
|
|
3893
|
-
// ../core/src/lanes/claude.ts
|
|
3894
|
-
import { execFile as execFile2 } from "child_process";
|
|
3895
|
-
import { mkdtempSync, rmSync } from "fs";
|
|
3896
|
-
import { homedir as homedir4, tmpdir } from "os";
|
|
3897
|
-
import { join as join7 } from "path";
|
|
5176
|
+
import { join as join10 } from "path";
|
|
3898
5177
|
|
|
3899
5178
|
// ../core/src/lanes/codex.ts
|
|
3900
5179
|
import { execFile as execFile3 } from "child_process";
|
|
5180
|
+
function fakeCodex(req, opts) {
|
|
5181
|
+
const text2 = "pong";
|
|
5182
|
+
const model = `openai/${opts.model ?? "codex"}`;
|
|
5183
|
+
const tokensIn = approxTokens(req.prompt + (req.system ?? ""));
|
|
5184
|
+
const tokensOut = approxTokens(text2);
|
|
5185
|
+
return { ok: true, text: text2, model, subscription: "codex", tokensIn, tokensOut, cacheRead: 0, cacheWrite: 0, costEquiv: costEquiv({ lane: req.lane, model, provider: "codex", subscription: "codex", tokens_in: tokensIn, tokens_out: tokensOut, duration_ms: 0 }), durationMs: 0, raw: { fake: true } };
|
|
5186
|
+
}
|
|
5187
|
+
function parseCodexJsonl(stdout) {
|
|
5188
|
+
let tokensIn = 0;
|
|
5189
|
+
let tokensOut = 0;
|
|
5190
|
+
let cacheRead = 0;
|
|
5191
|
+
let turns = 0;
|
|
5192
|
+
let text2 = "";
|
|
5193
|
+
let error;
|
|
5194
|
+
for (const line of stdout.split("\n")) {
|
|
5195
|
+
const t = line.trim();
|
|
5196
|
+
if (!t.startsWith("{")) continue;
|
|
5197
|
+
let j;
|
|
5198
|
+
try {
|
|
5199
|
+
j = JSON.parse(t);
|
|
5200
|
+
} catch {
|
|
5201
|
+
continue;
|
|
5202
|
+
}
|
|
5203
|
+
if (j.type === "turn.completed") {
|
|
5204
|
+
turns++;
|
|
5205
|
+
tokensIn += j.usage?.input_tokens ?? 0;
|
|
5206
|
+
tokensOut += j.usage?.output_tokens ?? 0;
|
|
5207
|
+
cacheRead += j.usage?.cached_input_tokens ?? 0;
|
|
5208
|
+
} else if (j.type === "item.completed" && j.item?.type === "agent_message" && typeof j.item.text === "string") text2 = j.item.text;
|
|
5209
|
+
else if (j.type === "error" || j.type === "turn.failed") error = j.error?.message ?? j.message ?? "codex reported an error";
|
|
5210
|
+
}
|
|
5211
|
+
return { tokensIn, tokensOut, cacheRead, text: text2, turns, ...error ? { error } : {} };
|
|
5212
|
+
}
|
|
5213
|
+
async function runCodex(req, opts = {}) {
|
|
5214
|
+
if (process.env.RELAY_FAKE_LANES === "1") return fakeCodex(req, opts);
|
|
5215
|
+
const bin = opts.bin ?? process.env.RELAY_CODEX_BIN ?? findOnPath("codex") ?? "codex";
|
|
5216
|
+
return withSubscriptionLock("codex", async () => {
|
|
5217
|
+
const args = ["exec", "--json", "--sandbox", req.readOnly ? "read-only" : "workspace-write", "-c", "agents.enabled=false"];
|
|
5218
|
+
if (opts.model) args.push("-m", opts.model);
|
|
5219
|
+
args.push(req.prompt);
|
|
5220
|
+
const env = { ...req.env ?? process.env };
|
|
5221
|
+
delete env["OPENAI_API_KEY"];
|
|
5222
|
+
const t0 = Date.now();
|
|
5223
|
+
const { stdout, stderr, code } = await new Promise((resolve2) => {
|
|
5224
|
+
const child = execFile3(
|
|
5225
|
+
bin,
|
|
5226
|
+
args,
|
|
5227
|
+
{ cwd: req.cwd, env, maxBuffer: 64 * 1024 * 1024, timeout: req.timeoutMs ?? 20 * 6e4 },
|
|
5228
|
+
(err, stdout2, stderr2) => resolve2({ stdout: String(stdout2), stderr: String(stderr2), code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0 })
|
|
5229
|
+
);
|
|
5230
|
+
child.stdin?.end();
|
|
5231
|
+
});
|
|
5232
|
+
const durationMs = Date.now() - t0;
|
|
5233
|
+
const p = parseCodexJsonl(stdout);
|
|
5234
|
+
const model = `openai/${opts.model ?? "codex"}`;
|
|
5235
|
+
const ok = p.turns > 0 && !p.error && code === 0;
|
|
5236
|
+
const res = {
|
|
5237
|
+
ok,
|
|
5238
|
+
text: p.text,
|
|
5239
|
+
model,
|
|
5240
|
+
subscription: "codex",
|
|
5241
|
+
tokensIn: p.tokensIn,
|
|
5242
|
+
tokensOut: p.tokensOut,
|
|
5243
|
+
cacheRead: p.cacheRead,
|
|
5244
|
+
cacheWrite: 0,
|
|
5245
|
+
costEquiv: costEquiv({ lane: req.lane, model, provider: "codex", subscription: "codex", tokens_in: p.tokensIn, tokens_out: p.tokensOut, cache_read: p.cacheRead, duration_ms: durationMs }),
|
|
5246
|
+
durationMs,
|
|
5247
|
+
raw: { turns: p.turns, code, stderr: stderr.slice(0, 2e3) }
|
|
5248
|
+
};
|
|
5249
|
+
if (!ok) res.error = (p.error ?? stderr ?? `codex exited ${code}`).slice(0, 500);
|
|
5250
|
+
return res;
|
|
5251
|
+
});
|
|
5252
|
+
}
|
|
3901
5253
|
|
|
3902
5254
|
// ../core/src/lanes/fx.ts
|
|
3903
5255
|
import { execFile as execFile4 } from "child_process";
|
|
3904
|
-
import { readFileSync as
|
|
5256
|
+
import { readFileSync as readFileSync5, writeFileSync as writeFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
|
|
3905
5257
|
import { homedir as homedir5 } from "os";
|
|
3906
|
-
import { join as
|
|
3907
|
-
var FX_HOME = process.env.FX_HOME_DIR ??
|
|
3908
|
-
var SETTINGS =
|
|
5258
|
+
import { join as join9 } from "path";
|
|
5259
|
+
var FX_HOME = process.env.FX_HOME_DIR ?? join9(homedir5(), ".fx");
|
|
5260
|
+
var SETTINGS = join9(FX_HOME, "settings.json");
|
|
5261
|
+
function readSettings() {
|
|
5262
|
+
try {
|
|
5263
|
+
return existsSync6(SETTINGS) ? JSON.parse(readFileSync5(SETTINGS, "utf8")) : {};
|
|
5264
|
+
} catch {
|
|
5265
|
+
return {};
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
function ledgerModelId(provider, model) {
|
|
5269
|
+
if (model.includes("/")) return model;
|
|
5270
|
+
if (provider === "codex") return `openai/${model}`;
|
|
5271
|
+
if (provider === "grok") return `xai/${model}`;
|
|
5272
|
+
return model;
|
|
5273
|
+
}
|
|
5274
|
+
async function runFx(req, opts) {
|
|
5275
|
+
const bin = opts.bin ?? process.env.RELAY_FX_BIN ?? join9(homedir5(), ".local", "bin", "fx");
|
|
5276
|
+
const subscription = opts.provider === "gateway" ? "gateway" : opts.provider;
|
|
5277
|
+
const fxProvider = opts.provider === "gateway" ? "vercel" : opts.provider;
|
|
5278
|
+
return withSubscriptionLock(`fx:${opts.provider}`, async () => {
|
|
5279
|
+
mkdirSync4(FX_HOME, { recursive: true });
|
|
5280
|
+
const before = readSettings();
|
|
5281
|
+
const models2 = { ...before["models"] ?? {}, [fxProvider]: opts.model };
|
|
5282
|
+
writeFileSync2(SETTINGS, JSON.stringify({ ...before, models: models2, provider: fxProvider }));
|
|
5283
|
+
const args = ["--context-limit", `skill_catalog_bytes=${opts.skillCatalogBytes ?? 1024}`];
|
|
5284
|
+
for (const d of req.addDirs ?? []) args.push("--add-dir", d);
|
|
5285
|
+
args.push("ask", "--json", "--no-save", "--quiet");
|
|
5286
|
+
if (req.system) args.push("--system", req.system);
|
|
5287
|
+
if (!req.readOnly) args.push("--auto");
|
|
5288
|
+
args.push("--", req.prompt);
|
|
5289
|
+
const t0 = Date.now();
|
|
5290
|
+
try {
|
|
5291
|
+
const { stdout, stderr, code } = await new Promise((resolve2) => {
|
|
5292
|
+
const child = execFile4(
|
|
5293
|
+
bin,
|
|
5294
|
+
args,
|
|
5295
|
+
{ cwd: req.cwd, env: { ...req.env ?? process.env, FX_MODEL: opts.model, FX_NO_OPEN_BROWSER: "1", FX_AUTO_UPGRADE: "0" }, maxBuffer: 64 * 1024 * 1024, timeout: req.timeoutMs ?? 20 * 6e4 },
|
|
5296
|
+
(err, stdout2, stderr2) => resolve2({ stdout: String(stdout2), stderr: String(stderr2), code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0 })
|
|
5297
|
+
);
|
|
5298
|
+
child.stdin?.end();
|
|
5299
|
+
});
|
|
5300
|
+
const durationMs = Date.now() - t0;
|
|
5301
|
+
let j;
|
|
5302
|
+
try {
|
|
5303
|
+
j = JSON.parse(stdout.trim().split("\n").filter(Boolean).pop() ?? "");
|
|
5304
|
+
} catch {
|
|
5305
|
+
}
|
|
5306
|
+
const model = ledgerModelId(opts.provider, j?.model || opts.model);
|
|
5307
|
+
const tokensIn = j?.usage?.input_tokens ?? 0;
|
|
5308
|
+
const tokensOut = j?.usage?.output_tokens ?? 0;
|
|
5309
|
+
const ok = !!j && j.exit_code === 0 && !j.error && code === 0;
|
|
5310
|
+
const res = {
|
|
5311
|
+
ok,
|
|
5312
|
+
text: j?.final_output || j?.output || "",
|
|
5313
|
+
model,
|
|
5314
|
+
subscription,
|
|
5315
|
+
tokensIn,
|
|
5316
|
+
tokensOut,
|
|
5317
|
+
cacheRead: 0,
|
|
5318
|
+
cacheWrite: 0,
|
|
5319
|
+
costEquiv: costEquiv({ lane: req.lane, model, provider: opts.provider, subscription, tokens_in: tokensIn, tokens_out: tokensOut, duration_ms: durationMs }),
|
|
5320
|
+
durationMs,
|
|
5321
|
+
raw: j ?? { stdout: stdout.slice(0, 2e3), stderr: stderr.slice(0, 2e3), code }
|
|
5322
|
+
};
|
|
5323
|
+
if (j?.session_id) res.sessionId = j.session_id;
|
|
5324
|
+
if (!ok) res.error = j?.error ?? (j?.output || stderr || `fx exited ${code}`).slice(0, 500);
|
|
5325
|
+
return res;
|
|
5326
|
+
} finally {
|
|
5327
|
+
writeFileSync2(SETTINGS, JSON.stringify(before));
|
|
5328
|
+
}
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
3909
5331
|
|
|
3910
5332
|
// ../core/src/lanes/grok.ts
|
|
3911
5333
|
import { execFile as execFile5 } from "child_process";
|
|
5334
|
+
function fakeGrok(req, opts) {
|
|
5335
|
+
const text2 = "pong";
|
|
5336
|
+
const model = `xai/${opts.model ?? "grok"}`;
|
|
5337
|
+
const tokensIn = approxTokens(req.prompt + (req.system ?? ""));
|
|
5338
|
+
const tokensOut = approxTokens(text2);
|
|
5339
|
+
return { ok: true, text: text2, model, subscription: "grok", tokensIn, tokensOut, cacheRead: 0, cacheWrite: 0, costEquiv: costEquiv({ lane: req.lane, model, provider: "grok", subscription: "grok", tokens_in: tokensIn, tokens_out: tokensOut, duration_ms: 0 }), durationMs: 0, raw: { fake: true } };
|
|
5340
|
+
}
|
|
5341
|
+
async function runGrok(req, opts = {}) {
|
|
5342
|
+
if (process.env.RELAY_FAKE_LANES === "1") return fakeGrok(req, opts);
|
|
5343
|
+
const bin = opts.bin ?? process.env.RELAY_GROK_BIN ?? findOnPath("grok") ?? "grok";
|
|
5344
|
+
return withSubscriptionLock("grok", async () => {
|
|
5345
|
+
const args = ["-p", req.prompt, "--output-format", "json", "--no-subagents", "--no-plan", "--permission-mode", req.readOnly ? "plan" : "acceptEdits"];
|
|
5346
|
+
if (opts.model) args.push("-m", opts.model);
|
|
5347
|
+
const env = { ...req.env ?? process.env };
|
|
5348
|
+
delete env["XAI_API_KEY"];
|
|
5349
|
+
const t0 = Date.now();
|
|
5350
|
+
const { stdout, stderr, code } = await new Promise((resolve2) => {
|
|
5351
|
+
const child = execFile5(
|
|
5352
|
+
bin,
|
|
5353
|
+
args,
|
|
5354
|
+
{ cwd: req.cwd, env, maxBuffer: 64 * 1024 * 1024, timeout: req.timeoutMs ?? 20 * 6e4 },
|
|
5355
|
+
(err, stdout2, stderr2) => resolve2({ stdout: String(stdout2), stderr: String(stderr2), code: err && "code" in err && typeof err.code === "number" ? err.code : err ? 1 : 0 })
|
|
5356
|
+
);
|
|
5357
|
+
child.stdin?.end();
|
|
5358
|
+
});
|
|
5359
|
+
let durationMs = Date.now() - t0;
|
|
5360
|
+
let j;
|
|
5361
|
+
try {
|
|
5362
|
+
j = JSON.parse(stdout.trim());
|
|
5363
|
+
} catch {
|
|
5364
|
+
}
|
|
5365
|
+
if (j?.duration_ms && j.duration_ms > 0) durationMs = j.duration_ms;
|
|
5366
|
+
const u = j?.usage ?? {};
|
|
5367
|
+
const tokensIn = u.input_tokens ?? 0;
|
|
5368
|
+
const tokensOut = u.output_tokens ?? 0;
|
|
5369
|
+
const cacheRead = u.cache_read_input_tokens ?? u.cached_input_tokens ?? 0;
|
|
5370
|
+
const cacheWrite = u.cache_creation_input_tokens ?? 0;
|
|
5371
|
+
const model = `xai/${j?.model || opts.model || "grok"}`;
|
|
5372
|
+
const ok = !!j && !j.is_error && code === 0;
|
|
5373
|
+
const res = {
|
|
5374
|
+
ok,
|
|
5375
|
+
text: j?.result ?? j?.text ?? j?.output ?? "",
|
|
5376
|
+
model,
|
|
5377
|
+
subscription: "grok",
|
|
5378
|
+
tokensIn,
|
|
5379
|
+
tokensOut,
|
|
5380
|
+
cacheRead,
|
|
5381
|
+
cacheWrite,
|
|
5382
|
+
costEquiv: costEquiv({ lane: req.lane, model, provider: "grok", subscription: "grok", tokens_in: tokensIn, tokens_out: tokensOut, cache_read: cacheRead, cache_write: cacheWrite, duration_ms: durationMs }),
|
|
5383
|
+
durationMs,
|
|
5384
|
+
raw: j ?? { stdout: stdout.slice(0, 2e3), stderr: stderr.slice(0, 2e3), code }
|
|
5385
|
+
};
|
|
5386
|
+
if (!ok) res.error = (j?.result || stderr || `grok exited ${code}`).slice(0, 500);
|
|
5387
|
+
return res;
|
|
5388
|
+
});
|
|
5389
|
+
}
|
|
3912
5390
|
|
|
3913
5391
|
// ../core/src/work/job.ts
|
|
3914
5392
|
var DEFAULT_JOB_TIMEOUT_MS = 60 * 6e4;
|
|
5393
|
+
var DEFAULT_MAX_TURNS = 40;
|
|
5394
|
+
var CODE_TOOLS = [
|
|
5395
|
+
"Read",
|
|
5396
|
+
"Edit",
|
|
5397
|
+
"Write",
|
|
5398
|
+
"Glob",
|
|
5399
|
+
"Grep",
|
|
5400
|
+
"Bash(git status*)",
|
|
5401
|
+
"Bash(git diff*)",
|
|
5402
|
+
"Bash(git log*)",
|
|
5403
|
+
"Bash(git add*)",
|
|
5404
|
+
"Bash(git commit*)",
|
|
5405
|
+
"Bash(git checkout*)",
|
|
5406
|
+
"Bash(git restore*)",
|
|
5407
|
+
// the project's own scripts and checks: the OS sandbox (lanes/sandbox.ts) is the boundary, the allowlist only shapes the work
|
|
5408
|
+
"Bash(pnpm install*)",
|
|
5409
|
+
"Bash(pnpm test*)",
|
|
5410
|
+
"Bash(pnpm typecheck*)",
|
|
5411
|
+
"Bash(pnpm build*)",
|
|
5412
|
+
"Bash(pnpm lint*)",
|
|
5413
|
+
"Bash(pnpm run *)",
|
|
5414
|
+
"Bash(pnpm -r *)",
|
|
5415
|
+
"Bash(npm ci*)",
|
|
5416
|
+
"Bash(npm install*)",
|
|
5417
|
+
"Bash(npm test*)",
|
|
5418
|
+
"Bash(npm run *)",
|
|
5419
|
+
"Bash(npx tsc*)",
|
|
5420
|
+
"Bash(npx eslint*)",
|
|
5421
|
+
"Bash(npx vitest*)",
|
|
5422
|
+
"Bash(npx next *)",
|
|
5423
|
+
"Bash(npx prettier*)",
|
|
5424
|
+
"Bash(yarn install*)",
|
|
5425
|
+
"Bash(yarn test*)",
|
|
5426
|
+
"Bash(yarn build*)",
|
|
5427
|
+
"Bash(yarn lint*)",
|
|
5428
|
+
"Bash(yarn run *)"
|
|
5429
|
+
];
|
|
5430
|
+
var CODE_DISALLOWED_TOOLS = ["Bash(git push*)", "Bash(git -c*)", "Bash(git config*)", "Bash(git remote*)", "Bash(curl*)", "Bash(wget*)"];
|
|
5431
|
+
var SANDBOX_UNAVAILABLE_SUMMARY = `${SANDBOX_UNAVAILABLE}: Claude Code could not set up its OS sandbox on this worker, so the job did not run (relay never runs the lane unsandboxed). Linux: sudo apt install bubblewrap socat (or dnf); macOS has it built in. \`relay work --no-sandbox\` is the recorded escape hatch.`;
|
|
5432
|
+
var laneRunner = (o = {}) => async (req, lane) => {
|
|
5433
|
+
const env = process.env;
|
|
5434
|
+
if (lane === "claude") {
|
|
5435
|
+
const model = env.RELAY_MODEL_CLAUDE ?? "opus";
|
|
5436
|
+
return { tool: "claude", ...await runClaude(req, { model, allowedTools: CODE_TOOLS, disallowedTools: CODE_DISALLOWED_TOOLS, maxTurns: DEFAULT_MAX_TURNS, sandbox: o.sandbox ?? true }) };
|
|
5437
|
+
}
|
|
5438
|
+
const fake = env.RELAY_FAKE_LANES === "1";
|
|
5439
|
+
if (lane === "codex") {
|
|
5440
|
+
if (fake || findOnPath("codex") || env.RELAY_CODEX_BIN) return { tool: "codex", ...await runCodex(req, { ...env.RELAY_MODEL_CODEX ? { model: env.RELAY_MODEL_CODEX } : {} }) };
|
|
5441
|
+
return { tool: "fx", ...await runFx(req, { provider: "codex", model: env.RELAY_MODEL_CODEX ?? "gpt-5.5" }) };
|
|
5442
|
+
}
|
|
5443
|
+
if (lane === "grok") {
|
|
5444
|
+
if (fake || findOnPath("grok") || env.RELAY_GROK_BIN) return { tool: "grok", ...await runGrok(req, { ...env.RELAY_MODEL_GROK ? { model: env.RELAY_MODEL_GROK } : {} }) };
|
|
5445
|
+
return { tool: "fx", ...await runFx(req, { provider: "grok", model: env.RELAY_MODEL_GROK ?? "grok-4.7" }) };
|
|
5446
|
+
}
|
|
5447
|
+
throw new Error(`no lane adapter for '${lane}'`);
|
|
5448
|
+
};
|
|
5449
|
+
var defaultLaneRunner = laneRunner();
|
|
5450
|
+
|
|
5451
|
+
// ../core/src/hooks/cache.ts
|
|
5452
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6, readdirSync as readdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
5453
|
+
import { homedir as homedir7 } from "os";
|
|
5454
|
+
import { join as join11 } from "path";
|
|
5455
|
+
var BRIEF_CACHE_TTL_MS = 10 * 6e4;
|
|
5456
|
+
function relayHome() {
|
|
5457
|
+
return process.env.RELAY_HOME ?? join11(homedir7(), ".harness");
|
|
5458
|
+
}
|
|
5459
|
+
function briefCacheDir(home = relayHome()) {
|
|
5460
|
+
return join11(home, "cache", "brief");
|
|
5461
|
+
}
|
|
5462
|
+
var safe = (s) => s.replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 60);
|
|
5463
|
+
function invalidateBriefCache(project, home) {
|
|
5464
|
+
const dir = briefCacheDir(home);
|
|
5465
|
+
if (!existsSync8(dir)) return 0;
|
|
5466
|
+
let n = 0;
|
|
5467
|
+
for (const f of readdirSync3(dir)) {
|
|
5468
|
+
if (!f.endsWith(".json")) continue;
|
|
5469
|
+
if (project && !f.startsWith(`${safe(project)}-`)) continue;
|
|
5470
|
+
rmSync3(join11(dir, f), { force: true });
|
|
5471
|
+
n++;
|
|
5472
|
+
}
|
|
5473
|
+
return n;
|
|
5474
|
+
}
|
|
5475
|
+
|
|
5476
|
+
// ../core/src/work/worker.ts
|
|
5477
|
+
var MAX_IDLE_MS = 5 * 6e4;
|
|
5478
|
+
var IDLE_HEARTBEAT_MS = 5 * 6e4;
|
|
3915
5479
|
|
|
3916
5480
|
// ../core/src/work/service.ts
|
|
3917
|
-
import { join as
|
|
5481
|
+
import { join as join12 } from "path";
|
|
5482
|
+
|
|
5483
|
+
// ../core/src/work/container.ts
|
|
5484
|
+
var CONTAINER_HOME = "/home/relay";
|
|
5485
|
+
var CONTAINER_RELAY_HOME = `${CONTAINER_HOME}/.harness`;
|
|
5486
|
+
var CONTAINER_CREDENTIALS = `${CONTAINER_RELAY_HOME}/credentials.json`;
|
|
3918
5487
|
|
|
3919
5488
|
// ../core/src/onboard/registry.ts
|
|
3920
5489
|
var ark = (id) => `https://docs.byteplus.com/en/docs/ModelArk/${id}`;
|
|
@@ -3981,22 +5550,22 @@ var models = [
|
|
|
3981
5550
|
import * as cheerio from "cheerio";
|
|
3982
5551
|
|
|
3983
5552
|
// ../core/src/onboard/skills.ts
|
|
3984
|
-
import { mkdirSync as
|
|
3985
|
-
import { join as
|
|
5553
|
+
import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, existsSync as existsSync9, rmSync as rmSync4, writeFileSync as writeFileSync4, readdirSync as readdirSync4, statSync as statSync2 } from "fs";
|
|
5554
|
+
import { join as join13 } from "path";
|
|
3986
5555
|
import { tmpdir as tmpdir2 } from "os";
|
|
3987
5556
|
import { execFileSync } from "child_process";
|
|
3988
5557
|
|
|
3989
5558
|
// ../core/src/state/project.ts
|
|
3990
|
-
import { readdirSync as
|
|
3991
|
-
import { join as
|
|
5559
|
+
import { readdirSync as readdirSync5, readFileSync as readFileSync9, statSync as statSync3 } from "fs";
|
|
5560
|
+
import { join as join14, relative, extname } from "path";
|
|
3992
5561
|
var MAX_BYTES = 200 * 1024;
|
|
3993
5562
|
|
|
3994
5563
|
// ../core/src/assets/store.ts
|
|
3995
5564
|
import { createHmac as createHmac2, randomBytes as randomBytes7, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
3996
|
-
import { createWriteStream, existsSync as
|
|
5565
|
+
import { createWriteStream, existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync5 } from "fs";
|
|
3997
5566
|
import { readFile, rm, stat, writeFile } from "fs/promises";
|
|
3998
|
-
import { homedir as
|
|
3999
|
-
import { dirname as dirname4, isAbsolute, join as
|
|
5567
|
+
import { homedir as homedir8 } from "os";
|
|
5568
|
+
import { dirname as dirname4, isAbsolute, join as join15, normalize, relative as relative2, sep } from "path";
|
|
4000
5569
|
import { pipeline } from "stream/promises";
|
|
4001
5570
|
import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
|
|
4002
5571
|
var PublishUnsupportedError = class extends Error {
|
|
@@ -4005,7 +5574,7 @@ var PublishUnsupportedError = class extends Error {
|
|
|
4005
5574
|
this.name = "PublishUnsupportedError";
|
|
4006
5575
|
}
|
|
4007
5576
|
};
|
|
4008
|
-
var ASSETS_HOME =
|
|
5577
|
+
var ASSETS_HOME = join15(process.env.RELAY_HOME ?? join15(homedir8(), ".harness"), "assets");
|
|
4009
5578
|
function assertSafeKey(key) {
|
|
4010
5579
|
if (!key || key.includes("\\") || key.startsWith("/") || key.split("/").some((s) => s === "" || s === "." || s === "..")) {
|
|
4011
5580
|
throw new Error(`unsafe storage key: ${key}`);
|
|
@@ -4022,7 +5591,7 @@ var LocalStore = class {
|
|
|
4022
5591
|
secret;
|
|
4023
5592
|
pathFor(key) {
|
|
4024
5593
|
assertSafeKey(key);
|
|
4025
|
-
const p = normalize(
|
|
5594
|
+
const p = normalize(join15(this.root, ...key.split("/")));
|
|
4026
5595
|
const rel = relative2(this.root, p);
|
|
4027
5596
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`unsafe storage key: ${key}`);
|
|
4028
5597
|
return p;
|
|
@@ -4040,12 +5609,12 @@ var LocalStore = class {
|
|
|
4040
5609
|
this.secret = Buffer.from(env, "utf8");
|
|
4041
5610
|
return this.secret;
|
|
4042
5611
|
}
|
|
4043
|
-
const file =
|
|
4044
|
-
if (!
|
|
4045
|
-
|
|
4046
|
-
|
|
5612
|
+
const file = join15(this.root, SIGNING_KEY_FILE);
|
|
5613
|
+
if (!existsSync10(file)) {
|
|
5614
|
+
mkdirSync8(this.root, { recursive: true });
|
|
5615
|
+
writeFileSync5(file, randomBytes7(32).toString("hex") + "\n", { mode: 384, flag: "wx" });
|
|
4047
5616
|
}
|
|
4048
|
-
this.secret = Buffer.from(
|
|
5617
|
+
this.secret = Buffer.from(readFileSync10(file, "utf8").trim(), "hex");
|
|
4049
5618
|
return this.secret;
|
|
4050
5619
|
}
|
|
4051
5620
|
sign(key, exp) {
|
|
@@ -4066,7 +5635,7 @@ ${exp}`).digest("base64url");
|
|
|
4066
5635
|
}
|
|
4067
5636
|
async put(key, data, _opts) {
|
|
4068
5637
|
const p = this.pathFor(key);
|
|
4069
|
-
|
|
5638
|
+
mkdirSync8(dirname4(p), { recursive: true });
|
|
4070
5639
|
if (Buffer.isBuffer(data)) await writeFile(p, data);
|
|
4071
5640
|
else await pipeline(data, createWriteStream(p));
|
|
4072
5641
|
}
|
|
@@ -4354,10 +5923,10 @@ function htmlBundleIndex(names) {
|
|
|
4354
5923
|
// ../core/src/assets/ingest.ts
|
|
4355
5924
|
import { execFile as execFile7 } from "child_process";
|
|
4356
5925
|
import { createHash as createHash4 } from "crypto";
|
|
4357
|
-
import { mkdtempSync as mkdtempSync2, rmSync as
|
|
5926
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync5, statSync as statSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
4358
5927
|
import { readFile as readFile2 } from "fs/promises";
|
|
4359
5928
|
import { tmpdir as tmpdir3 } from "os";
|
|
4360
|
-
import { basename, delimiter as delimiter2, join as
|
|
5929
|
+
import { basename, delimiter as delimiter2, join as join16 } from "path";
|
|
4361
5930
|
var IngestError = class extends Error {
|
|
4362
5931
|
constructor(message2) {
|
|
4363
5932
|
super(message2);
|
|
@@ -4368,7 +5937,7 @@ var MAX_THUMB = 512;
|
|
|
4368
5937
|
function onPath(bin) {
|
|
4369
5938
|
for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
|
|
4370
5939
|
if (!dir) continue;
|
|
4371
|
-
const p =
|
|
5940
|
+
const p = join16(dir, bin);
|
|
4372
5941
|
try {
|
|
4373
5942
|
if (statSync4(p).isFile()) return p;
|
|
4374
5943
|
} catch {
|
|
@@ -4387,9 +5956,9 @@ function run(bin, args, timeoutMs = 3e4) {
|
|
|
4387
5956
|
});
|
|
4388
5957
|
}
|
|
4389
5958
|
function tempFile(bytes, filename) {
|
|
4390
|
-
const dir = mkdtempSync2(
|
|
4391
|
-
const file =
|
|
4392
|
-
|
|
5959
|
+
const dir = mkdtempSync2(join16(tmpdir3(), "relay-asset-"));
|
|
5960
|
+
const file = join16(dir, basename(filename) || "asset");
|
|
5961
|
+
writeFileSync6(file, bytes);
|
|
4393
5962
|
return { dir, file };
|
|
4394
5963
|
}
|
|
4395
5964
|
async function ffprobe(file) {
|
|
@@ -4458,7 +6027,7 @@ async function probeBytes(bytes, filename, opts = {}) {
|
|
|
4458
6027
|
const p = await ffprobe(opts.pathForTools ?? tmp.file);
|
|
4459
6028
|
if (p) Object.assign(probe, p);
|
|
4460
6029
|
} finally {
|
|
4461
|
-
if (tmp)
|
|
6030
|
+
if (tmp) rmSync5(tmp.dir, { recursive: true, force: true });
|
|
4462
6031
|
}
|
|
4463
6032
|
}
|
|
4464
6033
|
return probe;
|
|
@@ -4476,7 +6045,7 @@ async function storeThumbnail(store, prefix, probe, bytes, filename, opts) {
|
|
|
4476
6045
|
try {
|
|
4477
6046
|
thumb = await videoPoster(opts.pathForTools ?? tmp.file, opts.thumbSize ?? MAX_THUMB);
|
|
4478
6047
|
} finally {
|
|
4479
|
-
if (tmp)
|
|
6048
|
+
if (tmp) rmSync5(tmp.dir, { recursive: true, force: true });
|
|
4480
6049
|
}
|
|
4481
6050
|
}
|
|
4482
6051
|
if (thumb) {
|
|
@@ -4550,54 +6119,29 @@ async function finalizeFromKey(db, store, input) {
|
|
|
4550
6119
|
}
|
|
4551
6120
|
|
|
4552
6121
|
// ../core/src/rules/owned-section.ts
|
|
4553
|
-
import { existsSync as
|
|
4554
|
-
import { dirname as dirname5, join as
|
|
4555
|
-
var STATE_FILE =
|
|
6122
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync7 } from "fs";
|
|
6123
|
+
import { dirname as dirname5, join as join17 } from "path";
|
|
6124
|
+
var STATE_FILE = join17(".relay", "rules.json");
|
|
4556
6125
|
|
|
4557
6126
|
// ../core/src/hooks/types.ts
|
|
4558
|
-
import { existsSync as
|
|
4559
|
-
import { homedir as homedir8 } from "os";
|
|
4560
|
-
import { delimiter as delimiter3, dirname as dirname6, join as join16 } from "path";
|
|
4561
|
-
|
|
4562
|
-
// ../core/src/hooks/cache.ts
|
|
4563
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync10, readdirSync as readdirSync4, readFileSync as readFileSync11, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
|
|
6127
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync10, readFileSync as readFileSync12, rmSync as rmSync6, statSync as statSync5, writeFileSync as writeFileSync8 } from "fs";
|
|
4564
6128
|
import { homedir as homedir9 } from "os";
|
|
4565
|
-
import { join as
|
|
4566
|
-
var BRIEF_CACHE_TTL_MS = 10 * 6e4;
|
|
4567
|
-
function relayHome() {
|
|
4568
|
-
return process.env.RELAY_HOME ?? join17(homedir9(), ".harness");
|
|
4569
|
-
}
|
|
4570
|
-
function briefCacheDir(home = relayHome()) {
|
|
4571
|
-
return join17(home, "cache", "brief");
|
|
4572
|
-
}
|
|
4573
|
-
var safe = (s) => s.replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 60);
|
|
4574
|
-
function invalidateBriefCache(project, home) {
|
|
4575
|
-
const dir = briefCacheDir(home);
|
|
4576
|
-
if (!existsSync11(dir)) return 0;
|
|
4577
|
-
let n = 0;
|
|
4578
|
-
for (const f of readdirSync4(dir)) {
|
|
4579
|
-
if (!f.endsWith(".json")) continue;
|
|
4580
|
-
if (project && !f.startsWith(`${safe(project)}-`)) continue;
|
|
4581
|
-
rmSync5(join17(dir, f), { force: true });
|
|
4582
|
-
n++;
|
|
4583
|
-
}
|
|
4584
|
-
return n;
|
|
4585
|
-
}
|
|
6129
|
+
import { delimiter as delimiter3, dirname as dirname6, join as join18 } from "path";
|
|
4586
6130
|
|
|
4587
6131
|
// ../core/src/hooks/claude.ts
|
|
4588
|
-
import { existsSync as
|
|
4589
|
-
import { join as
|
|
6132
|
+
import { existsSync as existsSync13 } from "fs";
|
|
6133
|
+
import { join as join19 } from "path";
|
|
4590
6134
|
|
|
4591
6135
|
// ../core/src/hooks/codex.ts
|
|
4592
|
-
import { existsSync as
|
|
4593
|
-
import { join as
|
|
6136
|
+
import { existsSync as existsSync14, readFileSync as readFileSync13 } from "fs";
|
|
6137
|
+
import { join as join20 } from "path";
|
|
4594
6138
|
|
|
4595
6139
|
// ../core/src/hooks/grok.ts
|
|
4596
|
-
import { existsSync as
|
|
4597
|
-
import { join as
|
|
6140
|
+
import { existsSync as existsSync15 } from "fs";
|
|
6141
|
+
import { join as join21 } from "path";
|
|
4598
6142
|
|
|
4599
6143
|
// ../core/src/memory/capture.ts
|
|
4600
|
-
import { readFileSync as
|
|
6144
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
4601
6145
|
import { spawnSync } from "child_process";
|
|
4602
6146
|
|
|
4603
6147
|
// ../mcp/src/server.ts
|
|
@@ -4625,12 +6169,20 @@ var TOOL_SCOPES = [
|
|
|
4625
6169
|
{ name: "relay_asset_request_upload", scope: "assets:write", description: "Reserve an asset id and get a one-time upload URL; PUT the bytes there, then call relay_asset_finalize." },
|
|
4626
6170
|
{ name: "relay_asset_finalize", scope: "assets:write", description: "Turn an uploaded object into an asset row (probe, thumbnail, record) after relay_asset_request_upload." },
|
|
4627
6171
|
{ name: "relay_task_create", scope: "tasks:write", description: "Open a task: a GitHub issue in the project's repo with the agent:<lane> label (and priority:<p>); returns task_id and issue_url. Idempotent on idempotency_key." },
|
|
4628
|
-
{ name: "relay_task_get", scope: "tasks:read", description: "One task by id (owner/repo#n) with its derived state: queued, claimed, running, review, done or failed, plus the worker, PR and
|
|
4629
|
-
{ name: "relay_task_list", scope: "tasks:read", description: "Tasks with derived state, newest first; filter by project and state." },
|
|
6172
|
+
{ name: "relay_task_get", scope: "tasks:read", description: "One task by id (owner/repo#n) with its derived state: queued, held, claimed, running, review, done or failed, plus the worker, PR, preview URL (preview_url, preview_state), the lane's run summary, usage and the review decision. held means the injection gate flagged the issue text; the task.held row in its feed carries the score and reasons." },
|
|
6173
|
+
{ name: "relay_task_list", scope: "tasks:read", description: "Tasks with derived state, newest first; filter by project and state. state=review is what waits for a decision; state=held is what the injection gate stopped and a person should look at." },
|
|
6174
|
+
{ name: "relay_task_approve", scope: "tasks:write", description: "Approve a task in review: relay squash-merges its PR through GitHub, deletes the branch and writes task.approved (the task is done). A bot may approve only with tasks:write and never a PR its own lane produced; GitHub's refusal comes back as an error with task.merge_failed in the feed." },
|
|
6175
|
+
{ name: "relay_task_request_changes", scope: "tasks:write", description: "Send a task in review back with a note: relay comments on the PR (relay: changes requested by <actor>: \u2026), writes task.changes_requested and queues the task; the next run gets the note in its brief and pushes to the same PR." },
|
|
6176
|
+
{ name: "relay_task_release", scope: "tokens:write", description: "Release a task the injection gate held (state held): relay writes task.released and the task is queued again; the next worker grades it again before running. Only for text a person has read and judged safe. A bot needs tokens:write and never releases a task it opened itself." },
|
|
6177
|
+
{ name: "relay_task_requeue", scope: "tasks:write", description: "Put a task back in the queue from any state but done, optionally on another lane: relay moves the agent:* label on the issue (best effort), releases a live worker claim, writes task.requeued and rings the doorbell; the next worker that serves the lane claims it and starts over. For a task stuck running (a cloud lane nobody wired, a run that never reported) or on the wrong lane." },
|
|
4630
6178
|
{ name: "relay_event_log", scope: "events:write", description: "Log an external.* event in the feed (a bot's own note). The actor is always bot:<token name>; the client cannot choose it." },
|
|
4631
6179
|
{ name: "relay_whoami", scope: null, description: "Who this connection is: token name, granted scopes, expiry and how it authenticated. Public; anonymous callers learn how to get access." },
|
|
4632
6180
|
{ name: "relay_webhook_subscribe", scope: "webhooks:write", description: "Register an outbound webhook for event kinds (exact, a prefix ending in '.', or '*'), optionally one project and a bearer for deliveries. The signing secret is returned once." },
|
|
4633
|
-
{ name: "relay_webhook_unsubscribe", scope: "webhooks:write", description: "Remove an outbound webhook by id." }
|
|
6181
|
+
{ name: "relay_webhook_unsubscribe", scope: "webhooks:write", description: "Remove an outbound webhook by id." },
|
|
6182
|
+
{ name: "relay_access_list", scope: "tokens:write", description: "Device requests waiting for a decision (user_code, client_name, scopes, requested and expiry times) and approvals the device has not polled yet. For the bot that approves other agents." },
|
|
6183
|
+
{ name: "relay_access_approve", scope: "tokens:write", description: "Approve (default) or deny a pending device request by its user code: the same decision the Access page makes. The device receives its token on its next poll; the decision is attributed to this token. A bot can approve only scopes it holds itself, and never tokens:write; wider requests are for a person at /activate." },
|
|
6184
|
+
{ name: "relay_invite_create", scope: "tokens:write", description: "Mint an invite code for an agent or machine trusted in advance: a name, scopes, optional expiry (24h default) and uses (1 default). The code is returned once; redeeming it needs no approval. A bot can invite only to scopes it holds itself, and never tokens:write." },
|
|
6185
|
+
{ name: "relay_invite_revoke", scope: "tokens:write", description: "Revoke a live invite by id or name; an unredeemed code stops working at once." }
|
|
4634
6186
|
];
|
|
4635
6187
|
var toolDescription = (name) => TOOL_SCOPES.find((t) => t.name === name)?.description ?? name;
|
|
4636
6188
|
function capText(text2, max = RESULT_TOKENS) {
|
|
@@ -4693,7 +6245,7 @@ function createRelayServer(db, opts) {
|
|
|
4693
6245
|
} catch {
|
|
4694
6246
|
}
|
|
4695
6247
|
};
|
|
4696
|
-
const
|
|
6248
|
+
const emit2 = async (row) => {
|
|
4697
6249
|
if (!row || !opts.afterEvent) return;
|
|
4698
6250
|
try {
|
|
4699
6251
|
await opts.afterEvent(row);
|
|
@@ -4810,7 +6362,7 @@ function createRelayServer(db, opts) {
|
|
|
4810
6362
|
if (!await repo_exports.getProject(db, project)) return fail(`unknown project '${project}'; run relay link in that repo first`);
|
|
4811
6363
|
const src = source ?? actor.replace(/^(agent|human):/, "");
|
|
4812
6364
|
const row = supersedes_id ? await repo_exports.supersedeDecision(db, supersedes_id, { text: t, source: src }) : await repo_exports.addDecision(db, { project, text: t, source: src });
|
|
4813
|
-
await
|
|
6365
|
+
await emit2(await repo_exports.writeEvent(db, { kind: "decision.captured", actor, project, summary: t.slice(0, 200), payload: { decision_id: row.id, source: src, superseded: supersedes_id ?? null } }));
|
|
4814
6366
|
afterWrite(project);
|
|
4815
6367
|
return text(`recorded decision #${row.id}${supersedes_id ? ` (supersedes #${supersedes_id})` : ""}`);
|
|
4816
6368
|
}
|
|
@@ -4832,7 +6384,7 @@ function createRelayServer(db, opts) {
|
|
|
4832
6384
|
if (g) return g;
|
|
4833
6385
|
if (scope !== "global" && !scope_id) return fail(`scope '${scope}' needs scope_id`);
|
|
4834
6386
|
await repo_exports.upsertPreference(db, { key, value, scope, scope_id: scope === "global" ? null : scope_id, reason: reason ?? null });
|
|
4835
|
-
await
|
|
6387
|
+
await emit2(await repo_exports.writeEvent(db, { kind: "preference.set", actor, project: scope === "project" ? scope_id : null, summary: `${key} = ${value} (${scope}${scope_id ? `: ${scope_id}` : ""})`, payload: { key, value, scope, scope_id: scope_id ?? null } }));
|
|
4836
6388
|
afterWrite(scope === "project" ? scope_id : void 0);
|
|
4837
6389
|
return text(`set ${key} = ${value} at ${scope}${scope_id ? ` ${scope_id}` : ""}`);
|
|
4838
6390
|
}
|
|
@@ -5007,7 +6559,7 @@ ${p.summary}` : head);
|
|
|
5007
6559
|
project: z.string().min(1).describe("project slug (relay link registered it)"),
|
|
5008
6560
|
title: z.string().min(1).max(256),
|
|
5009
6561
|
body: z.string().max(6e4).optional().describe("issue body, markdown; the brief the lane reads"),
|
|
5010
|
-
lane: z.enum(TASK_LANES).describe("
|
|
6562
|
+
lane: z.enum(TASK_LANES).optional().describe("claude, codex or grok run on the user's own workers (machines running relay work) and are the normal choice; default claude. claude-cloud and codex-cloud hand the task to the vendor's hosted tier and only work when the operator has wired it; do not pick them unless asked to."),
|
|
5011
6563
|
priority: z.enum(TASK_PRIORITIES).optional(),
|
|
5012
6564
|
idempotency_key: z.string().min(1).max(200).optional().describe("repeat calls with the same key return the same task and open nothing")
|
|
5013
6565
|
}
|
|
@@ -5016,9 +6568,10 @@ ${p.summary}` : head);
|
|
|
5016
6568
|
const g = gate("tasks:write", "relay_task_create");
|
|
5017
6569
|
if (g) return g;
|
|
5018
6570
|
try {
|
|
5019
|
-
const
|
|
5020
|
-
|
|
5021
|
-
|
|
6571
|
+
const lane = args.lane ?? "claude";
|
|
6572
|
+
const r = await openTask(db, opts.github, { ...args, lane, actor: externalActor(actor) });
|
|
6573
|
+
for (const e of r.events) await emit2(e);
|
|
6574
|
+
return json({ task_id: r.task_id, issue_url: r.issue_url, created: r.created, state: r.task.state, lane });
|
|
5022
6575
|
} catch (e) {
|
|
5023
6576
|
return fail(`relay: ${e instanceof Error ? e.message : String(e)}${e instanceof TaskCreateError && e.status === 503 ? " (relay_task_create needs GitHub access on the server)" : ""}`);
|
|
5024
6577
|
}
|
|
@@ -5049,6 +6602,95 @@ ${p.summary}` : head);
|
|
|
5049
6602
|
return json({ tasks: rows, count: rows.length });
|
|
5050
6603
|
}
|
|
5051
6604
|
);
|
|
6605
|
+
const reviewFail = (e, tool) => fail(`relay: ${e instanceof Error ? e.message : String(e)}${e instanceof TaskReviewError && e.status === 503 ? ` (${tool} needs GitHub access on the server)` : ""}`);
|
|
6606
|
+
server.registerTool(
|
|
6607
|
+
"relay_task_approve",
|
|
6608
|
+
{ title: "Approve and merge a task", description: toolDescription("relay_task_approve"), inputSchema: { id: z.string().min(3).describe("owner/repo#n") } },
|
|
6609
|
+
async ({ id }) => {
|
|
6610
|
+
const g = gate("tasks:write", "relay_task_approve");
|
|
6611
|
+
if (g) return g;
|
|
6612
|
+
try {
|
|
6613
|
+
const r = await approveTask(db, opts.github, id.trim(), externalActor(actor), { scopes: [...granted] });
|
|
6614
|
+
for (const e of r.events) await emit2(e);
|
|
6615
|
+
afterWrite(r.task.project);
|
|
6616
|
+
return json({ task_id: r.task.id, state: r.task.state, pr_url: r.task.pr_url, merged_at: r.task.merged_at, review: r.task.review });
|
|
6617
|
+
} catch (e) {
|
|
6618
|
+
return reviewFail(e, "relay_task_approve");
|
|
6619
|
+
}
|
|
6620
|
+
}
|
|
6621
|
+
);
|
|
6622
|
+
server.registerTool(
|
|
6623
|
+
"relay_task_request_changes",
|
|
6624
|
+
{
|
|
6625
|
+
title: "Request changes on a task",
|
|
6626
|
+
description: toolDescription("relay_task_request_changes"),
|
|
6627
|
+
inputSchema: { id: z.string().min(3).describe("owner/repo#n"), note: z.string().min(1).max(1e4).describe("what should change; written for the model that will address it") }
|
|
6628
|
+
},
|
|
6629
|
+
async ({ id, note }) => {
|
|
6630
|
+
const g = gate("tasks:write", "relay_task_request_changes");
|
|
6631
|
+
if (g) return g;
|
|
6632
|
+
try {
|
|
6633
|
+
const r = await requestChanges(db, opts.github, id.trim(), externalActor(actor), note, { scopes: [...granted] });
|
|
6634
|
+
for (const e of r.events) await emit2(e);
|
|
6635
|
+
afterWrite(r.task.project);
|
|
6636
|
+
return json({ task_id: r.task.id, state: r.task.state, pr_url: r.task.pr_url, review: r.task.review, next: "the next worker run gets the note under Review feedback in its brief and pushes to the same PR" });
|
|
6637
|
+
} catch (e) {
|
|
6638
|
+
return reviewFail(e, "relay_task_request_changes");
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
);
|
|
6642
|
+
server.registerTool(
|
|
6643
|
+
"relay_task_requeue",
|
|
6644
|
+
{
|
|
6645
|
+
title: "Requeue a task",
|
|
6646
|
+
description: toolDescription("relay_task_requeue"),
|
|
6647
|
+
inputSchema: {
|
|
6648
|
+
id: z.string().min(3).describe("owner/repo#n"),
|
|
6649
|
+
lane: z.enum(TASK_LANES).optional().describe("move the task to this lane; claude, codex or grok run on the user's own workers and are the normal choice. Omitted keeps the lane it has."),
|
|
6650
|
+
reason: z.string().max(2e3).optional().describe("why, for the feed")
|
|
6651
|
+
}
|
|
6652
|
+
},
|
|
6653
|
+
async ({ id, lane, reason }) => {
|
|
6654
|
+
const g = gate("tasks:write", "relay_task_requeue");
|
|
6655
|
+
if (g) return g;
|
|
6656
|
+
try {
|
|
6657
|
+
const r = await requeueTask(db, opts.github, id.trim(), externalActor(actor), { lane: lane ?? null, reason: reason ?? null, scopes: [...granted] });
|
|
6658
|
+
for (const e of r.events) await emit2(e);
|
|
6659
|
+
afterWrite(r.task.project);
|
|
6660
|
+
const github = r.events[0]?.payload.github;
|
|
6661
|
+
return json({
|
|
6662
|
+
task_id: r.task.id,
|
|
6663
|
+
state: r.task.state,
|
|
6664
|
+
lane: r.task.lane,
|
|
6665
|
+
runner: r.task.runner,
|
|
6666
|
+
github: github ?? null,
|
|
6667
|
+
next: r.task.lane && r.task.lane.endsWith("-cloud") ? "a cloud lane: nothing dispatches it until the operator wires that lane; requeue on claude, codex or grok for a worker to pick it up" : "the next idle worker that serves the lane claims it and starts over from the base branch"
|
|
6668
|
+
});
|
|
6669
|
+
} catch (e) {
|
|
6670
|
+
return fail(`relay: ${e instanceof TaskRequeueError || e instanceof Error ? e.message : String(e)}`);
|
|
6671
|
+
}
|
|
6672
|
+
}
|
|
6673
|
+
);
|
|
6674
|
+
server.registerTool(
|
|
6675
|
+
"relay_task_release",
|
|
6676
|
+
{
|
|
6677
|
+
title: "Release a held task",
|
|
6678
|
+
description: toolDescription("relay_task_release"),
|
|
6679
|
+
inputSchema: { id: z.string().min(3).describe("owner/repo#n"), note: z.string().max(2e3).optional().describe("why it is safe to run, for the feed") }
|
|
6680
|
+
},
|
|
6681
|
+
async ({ id, note }) => {
|
|
6682
|
+
const g = gate("tokens:write", "relay_task_release");
|
|
6683
|
+
if (g) return g;
|
|
6684
|
+
try {
|
|
6685
|
+
const r = await releaseTask(db, id.trim(), externalActor(actor), { note: note ?? null, scopes: [...granted] });
|
|
6686
|
+
for (const e of r.events) await emit2(e);
|
|
6687
|
+
afterWrite(r.task.project);
|
|
6688
|
+
return json({ task_id: r.task.id, state: r.task.state, lane: r.task.lane, next: "the next idle worker that serves the lane claims it; its gate grades the text again (a pre-check marker holds it again unless the issue is edited or the worker runs with RELAY_GATE=off)" });
|
|
6689
|
+
} catch (e) {
|
|
6690
|
+
return fail(`relay: ${e instanceof TaskGateError || e instanceof Error ? e.message : String(e)}`);
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
);
|
|
5052
6694
|
server.registerTool("relay_whoami", { title: "Who am I", description: toolDescription("relay_whoami"), inputSchema: {} }, async () => {
|
|
5053
6695
|
const identity = opts.identity ?? (opts.canWrite ? { name: actor, expires_at: null, source: "local" } : { name: "anonymous", expires_at: null, source: "anonymous" });
|
|
5054
6696
|
return json({
|
|
@@ -5093,6 +6735,93 @@ ${p.summary}` : head);
|
|
|
5093
6735
|
const removed = await repo_exports.deleteWebhook(db, id);
|
|
5094
6736
|
return removed ? json({ removed: id }) : fail(`relay: no webhook ${id}`);
|
|
5095
6737
|
});
|
|
6738
|
+
const oauth = { issuer: opts.oauth?.issuer ?? "relay", ...opts.oauth?.now ? { now: opts.oauth.now } : {}, onEvent: (row) => emit2(row) };
|
|
6739
|
+
const site = opts.oauth?.issuer?.replace(/\/+$/, "");
|
|
6740
|
+
const accessView = (r) => ({ user_code: r.user_code, client_name: r.client_name, scopes: r.scopes, state: r.state, requested_at: r.created_at, expires_at: r.expires_at });
|
|
6741
|
+
const oauthFail = (e, tool) => {
|
|
6742
|
+
if (!(e instanceof OAuthError)) return fail(`relay: ${e instanceof Error ? e.message : String(e)}`);
|
|
6743
|
+
const next = e.status === 403 ? "" : tool === "relay_access_approve" ? e.code === "expired_token" || /already|Unknown code/.test(e.description) ? " Call relay_access_list to see what is waiting; if the code is not there, ask the device to request a fresh one (it starts the device flow again and shows a new RLAY-XXXX) and approve that." : " Codes look like RLAY-XXXX; relay_access_list shows the pending ones." : "";
|
|
6744
|
+
return fail(`relay: ${e.description}${next}`);
|
|
6745
|
+
};
|
|
6746
|
+
server.registerTool("relay_access_list", { title: "Pending access requests", description: toolDescription("relay_access_list"), inputSchema: {} }, async () => {
|
|
6747
|
+
const g = gate("tokens:write", "relay_access_list");
|
|
6748
|
+
if (g) return g;
|
|
6749
|
+
const rows = await listRecentAccess(db, oauth);
|
|
6750
|
+
const pending = rows.filter((r) => r.state === "pending").map(accessView);
|
|
6751
|
+
const approved = rows.filter((r) => r.state === "approved").map(accessView);
|
|
6752
|
+
return json({
|
|
6753
|
+
pending,
|
|
6754
|
+
approved_awaiting_poll: approved,
|
|
6755
|
+
count: pending.length,
|
|
6756
|
+
...pending.length ? { next: 'relay_access_approve({ user_code }) approves one; { decision: "deny" } refuses it.' } : {}
|
|
6757
|
+
});
|
|
6758
|
+
});
|
|
6759
|
+
server.registerTool(
|
|
6760
|
+
"relay_access_approve",
|
|
6761
|
+
{
|
|
6762
|
+
title: "Approve an access request",
|
|
6763
|
+
description: toolDescription("relay_access_approve"),
|
|
6764
|
+
inputSchema: {
|
|
6765
|
+
user_code: z.string().min(4).max(20).describe("the code the device showed, RLAY-XXXX (case and dashes do not matter)"),
|
|
6766
|
+
decision: z.enum(["approve", "deny"]).optional().describe("approve (default) or deny")
|
|
6767
|
+
}
|
|
6768
|
+
},
|
|
6769
|
+
async ({ user_code, decision }) => {
|
|
6770
|
+
const g = gate("tokens:write", "relay_access_approve");
|
|
6771
|
+
if (g) return g;
|
|
6772
|
+
try {
|
|
6773
|
+
const row = await decideDeviceRequest(db, user_code, decision ?? "approve", externalActor(actor), oauth, [...granted]);
|
|
6774
|
+
return json({
|
|
6775
|
+
user_code: row.user_code,
|
|
6776
|
+
status: row.status,
|
|
6777
|
+
client_name: row.client_name,
|
|
6778
|
+
scopes: row.scopes,
|
|
6779
|
+
decided_by: row.actor,
|
|
6780
|
+
...row.status === "approved" ? { next: `${row.client_name} receives its token (oauth:${row.client_name}) on its next poll of /oauth/token; nothing else to do.` } : {}
|
|
6781
|
+
});
|
|
6782
|
+
} catch (e) {
|
|
6783
|
+
return oauthFail(e, "relay_access_approve");
|
|
6784
|
+
}
|
|
6785
|
+
}
|
|
6786
|
+
);
|
|
6787
|
+
server.registerTool(
|
|
6788
|
+
"relay_invite_create",
|
|
6789
|
+
{
|
|
6790
|
+
title: "Mint an invite code",
|
|
6791
|
+
description: toolDescription("relay_invite_create"),
|
|
6792
|
+
inputSchema: {
|
|
6793
|
+
name: z.string().min(1).max(80).describe("who it is for; the token it yields is named oauth:<name>"),
|
|
6794
|
+
scopes: z.array(z.enum(TOKEN_SCOPES)).min(1).describe("exactly the scopes the token will carry"),
|
|
6795
|
+
expires_in: z.union([z.string().min(1), z.number().int().positive()]).optional().describe("a duration like 1h, 24h, 7d, or seconds; default 24h"),
|
|
6796
|
+
uses: z.number().int().min(1).max(1e3).optional().describe("how many times the code may be redeemed; default 1")
|
|
6797
|
+
}
|
|
6798
|
+
},
|
|
6799
|
+
async ({ name, scopes, expires_in, uses }) => {
|
|
6800
|
+
const g = gate("tokens:write", "relay_invite_create");
|
|
6801
|
+
if (g) return g;
|
|
6802
|
+
const ttl = expires_in === void 0 ? void 0 : parseDuration(expires_in);
|
|
6803
|
+
if (expires_in !== void 0 && !ttl) return fail("relay: expires_in must be a duration like 24h, 7d, or a number of seconds.");
|
|
6804
|
+
try {
|
|
6805
|
+
const r = await createInvite(db, { name, scopes, ...ttl ? { expiresInSeconds: ttl } : {}, ...uses ? { uses } : {}, createdBy: externalActor(actor), creatorScopes: [...granted] }, oauth);
|
|
6806
|
+
const where = site ?? "<site>";
|
|
6807
|
+
return json({
|
|
6808
|
+
invite: r.invite,
|
|
6809
|
+
code: r.code,
|
|
6810
|
+
setup: `relay setup --site ${where} --invite ${r.code}`,
|
|
6811
|
+
redeem: `POST ${where}/oauth/token with grant_type=${GRANT_INVITE} invite=${r.code} client_name=<who is redeeming>`,
|
|
6812
|
+
note: "The code is shown once. Hand it to the agent or machine it is for; whoever redeems it gets a token named oauth:" + r.invite.name + " with exactly these scopes and no approval step."
|
|
6813
|
+
});
|
|
6814
|
+
} catch (e) {
|
|
6815
|
+
return oauthFail(e, "relay_invite_create");
|
|
6816
|
+
}
|
|
6817
|
+
}
|
|
6818
|
+
);
|
|
6819
|
+
server.registerTool("relay_invite_revoke", { title: "Revoke an invite", description: toolDescription("relay_invite_revoke"), inputSchema: { id_or_name: z.string().min(1).describe("the invite's id or the name it was created for") } }, async ({ id_or_name }) => {
|
|
6820
|
+
const g = gate("tokens:write", "relay_invite_revoke");
|
|
6821
|
+
if (g) return g;
|
|
6822
|
+
const row = await revokeInvite2(db, id_or_name.trim(), externalActor(actor), oauth);
|
|
6823
|
+
return row ? json({ revoked: row }) : fail(`relay: no live invite matches '${id_or_name}'; it may be spent, expired or already revoked.`);
|
|
6824
|
+
});
|
|
5096
6825
|
server.registerTool(
|
|
5097
6826
|
"relay_event_log",
|
|
5098
6827
|
{
|
|
@@ -5112,7 +6841,7 @@ ${p.summary}` : head);
|
|
|
5112
6841
|
if (g) return g;
|
|
5113
6842
|
if (project && !await repo_exports.getProject(db, project)) return fail(`relay: unknown project '${project}'`);
|
|
5114
6843
|
const row = await repo_exports.writeEvent(db, { kind, actor: externalActor(actor), project: project ?? null, summary, url: url ?? null, cost_equiv: cost_equiv ?? 0, payload: source ? { source } : {} });
|
|
5115
|
-
await
|
|
6844
|
+
await emit2(row);
|
|
5116
6845
|
return json(row ? { id: row.id, ts: row.ts, kind: row.kind, actor: row.actor } : { deduped: true });
|
|
5117
6846
|
}
|
|
5118
6847
|
);
|
|
@@ -5132,6 +6861,8 @@ async function main() {
|
|
|
5132
6861
|
assets: { store: openAssetStore() },
|
|
5133
6862
|
...embed ? { embed } : {},
|
|
5134
6863
|
...github ? { github } : {},
|
|
6864
|
+
// Invites minted here live in this database; the setup line names the site only when this machine has one configured.
|
|
6865
|
+
...h.cfg.site ? { oauth: { issuer: h.cfg.site } } : {},
|
|
5135
6866
|
afterEvent: (row) => dispatchEvent(h.db, row).then(() => void 0)
|
|
5136
6867
|
});
|
|
5137
6868
|
const transport = new StdioServerTransport();
|