loki-mode 10.6.7 → 10.6.9
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/README.md +44 -9
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/backlog.py +167 -36
- package/autonomy/lib/workspace.py +384 -0
- package/autonomy/lib/workspace_comment.py +91 -0
- package/autonomy/lib/worktree_prep.py +143 -0
- package/autonomy/loki +96 -3
- package/bin/loki +3 -3
- package/completions/_loki +2 -0
- package/completions/loki.bash +1 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_start.py +26 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
- package/docs/WORKSPACES.md +36 -0
- package/docs/alternative-installations.md +3 -1
- package/docs/control-plane-container.md +50 -0
- package/docs/dashboard-guide.md +4 -0
- package/docs/dev/architecture-reference.md +4 -0
- package/docs/dev/project-structure.md +1 -1
- package/docs/exit-codes.md +1 -0
- package/docs/slack.md +34 -0
- package/docs/trackers.md +27 -0
- package/docs/v10/BOARD.md +74 -14
- package/docs/v10/CONTROL-PLANE.md +17 -6
- package/docs/v10/D51-PHASE-B.md +3 -1
- package/docs/v10/D61-SPEED.md +11 -2
- package/docs/v10/D63-CARDS.md +354 -0
- package/docs/v10/DECISIONS.md +63 -0
- package/docs/v10/DEPS.md +6 -2
- package/docs/v10/E2E-D65.md +87 -0
- package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
- package/docs/v10/PROGRESS.md +63 -0
- package/docs/v10/ROADMAP-D63.md +35 -0
- package/loki-ts/dist/loki.js +612 -556
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +76 -0
- package/mcp/tests/test_v10_tools.py +193 -0
- package/mcp/v10_tools.py +182 -0
- package/package.json +1 -1
- package/packages/control-plane/dist/server.js +131 -9
- package/packages/control-plane/drizzle/0001_group_id.sql +3 -0
- package/packages/control-plane/drizzle/meta/_journal.json +8 -1
- package/packages/control-plane/src/db/schema.ts +3 -0
- package/packages/control-plane/src/server/answer.ts +33 -0
- package/packages/control-plane/src/server/app.ts +22 -2
- package/packages/control-plane/src/server/auth.ts +51 -0
- package/packages/control-plane/src/server/runs.ts +6 -3
- package/packages/control-plane/src/server/serve.ts +13 -3
- package/packages/control-plane/src/shipper/discover.ts +29 -0
- package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
- package/packages/control-plane/ui/dist/assets/index-Jt31nrK-.js +114 -0
- package/packages/control-plane/ui/dist/index.html +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/web-app/dist/assets/{AdminPage-DyjnUcaP.js → AdminPage-CZKZLuWU.js} +1 -1
- package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-Dcy9kO2E.js} +1 -1
- package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DUotUSyx.js} +1 -1
- package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CFz_zfMZ.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-BhRw_oiM.js} +1 -1
- package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-DfnsRTTC.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-Dn0gUu3D.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-Co-xi282.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-CoCpfexq.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-Dg0Eg6F_.js} +1 -1
- package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-BysxL4mV.js} +2 -2
- package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-CQ366p_d.js} +1 -1
- package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-DD7YcbNq.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-BYz49xQj.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CKs09MeO.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-Ci2BIA_D.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-ClwMH0eu.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-Cm0rCX-y.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-CxvESt8X.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-W_CGYPOY.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-feVvRvys.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-C-pUWMmF.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-j5ibjKY3.js} +1 -1
- package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-TTdVYMa_.js} +1 -1
- package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-DeoTFwpv.js} +1 -1
- package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-B5SghxqU.js} +1 -1
- package/web-app/dist/assets/{check-BGAbHlvD.js → check-k8SzsdbE.js} +1 -1
- package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DHnEllFz.js} +1 -1
- package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-DW_SLdcB.js} +1 -1
- package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-Bmoaxy09.js} +1 -1
- package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-eg3tzXTh.js} +1 -1
- package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-VAfXihhD.js} +1 -1
- package/web-app/dist/assets/{database-DMmkAotn.js → database-CY3_-5aK.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-DYC0OLlg.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-0X3SveKz.js} +1 -1
- package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-B_oh_ukl.js} +1 -1
- package/web-app/dist/assets/{globe-DxotVWt8.js → globe-F0tLA8WJ.js} +1 -1
- package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-DrlvBmfW.js} +1 -1
- package/web-app/dist/assets/{index-BfhHMwi8.js → index-YihVaZAj.js} +3 -3
- package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-CbJfn5UC.js} +1 -1
- package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-CpmOggou.js} +1 -1
- package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-BGJ6QWrI.js} +1 -1
- package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BTmH4Xto.js} +1 -1
- package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-mKQ7hz-8.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-vPetpLsm.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-DiJ8GFhj.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-DpPQIlQR.js} +1 -1
- package/web-app/dist/assets/{server-CmMWPIjS.js → server-CiaV4drx.js} +1 -1
- package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-BaaQqyco.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-2zNCKp0Q.js} +1 -1
- package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CiVDx5Co.js} +1 -1
- package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-tcxvw5BH.js} +1 -1
- package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-C2bv2_v_.js} +1 -1
- package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-DS4VPUAp.js} +1 -1
- package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-JggBcVln.js} +1 -1
- package/web-app/dist/assets/{user-IBI-k10o.js → user-CvavvOxM.js} +1 -1
- package/web-app/dist/index.html +1 -1
- package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
|
@@ -16,7 +16,7 @@ var __export = (target, all) => {
|
|
|
16
16
|
|
|
17
17
|
// src/server/app.ts
|
|
18
18
|
import { existsSync as existsSync2, statSync } from "fs";
|
|
19
|
-
import { join as
|
|
19
|
+
import { join as join3, resolve, sep } from "path";
|
|
20
20
|
|
|
21
21
|
// node_modules/hono/dist/request/constants.js
|
|
22
22
|
var GET_MATCH_RESULT = Symbol();
|
|
@@ -5535,11 +5535,14 @@ var runs = sqliteTable("runs", {
|
|
|
5535
5535
|
lastSeq: integer("last_seq").notNull(),
|
|
5536
5536
|
lastEventAt: text("last_event_at"),
|
|
5537
5537
|
tampered: integer("tampered").notNull(),
|
|
5538
|
-
conflict: integer("conflict").notNull().default(0)
|
|
5538
|
+
conflict: integer("conflict").notNull().default(0),
|
|
5539
|
+
groupId: text("group_id"),
|
|
5540
|
+
unitId: text("unit_id")
|
|
5539
5541
|
}, (t) => [
|
|
5540
5542
|
primaryKey({ columns: [t.sourceId, t.runId] }),
|
|
5541
5543
|
index("runs_started").on(t.startedAt),
|
|
5542
|
-
index("runs_verdict").on(t.verdict)
|
|
5544
|
+
index("runs_verdict").on(t.verdict),
|
|
5545
|
+
index("runs_group").on(t.groupId)
|
|
5543
5546
|
]);
|
|
5544
5547
|
|
|
5545
5548
|
// src/db/migrate.ts
|
|
@@ -5581,6 +5584,11 @@ function validateEnvelope(x) {
|
|
|
5581
5584
|
return "stage must be a string or null";
|
|
5582
5585
|
if (!isObj(x.data))
|
|
5583
5586
|
return "data must be an object";
|
|
5587
|
+
for (const k of ["group_id", "unit_id"])
|
|
5588
|
+
if (k in x.data && (typeof x.data[k] !== "string" || x.data[k] === ""))
|
|
5589
|
+
return `${k} must be a non-empty string`;
|
|
5590
|
+
if ("deps" in x.data && (!Array.isArray(x.data.deps) || x.data.deps.some((d) => typeof d !== "string")))
|
|
5591
|
+
return "deps must be a string array";
|
|
5584
5592
|
return null;
|
|
5585
5593
|
}
|
|
5586
5594
|
function fold(events2) {
|
|
@@ -5658,6 +5666,35 @@ var RULES = [
|
|
|
5658
5666
|
];
|
|
5659
5667
|
var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
|
|
5660
5668
|
|
|
5669
|
+
// src/server/answer.ts
|
|
5670
|
+
import { chmodSync, mkdirSync, writeFileSync } from "fs";
|
|
5671
|
+
import { homedir } from "os";
|
|
5672
|
+
import { join as join2 } from "path";
|
|
5673
|
+
var MAX_ANSWER = 4000;
|
|
5674
|
+
var ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5675
|
+
var defaultAnswerDir = (env = process.env) => env.LOKI_CONTROL_ANSWER_DIR || join2(env.HOME || homedir(), ".loki", "control", "answers");
|
|
5676
|
+
function blockedQuestion(evs, verdict) {
|
|
5677
|
+
if (verdict !== "SPEC_CONFLICT")
|
|
5678
|
+
return null;
|
|
5679
|
+
const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
|
|
5680
|
+
return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
|
|
5681
|
+
}
|
|
5682
|
+
function writeAnswer(dir, source, run, answer) {
|
|
5683
|
+
if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes(".."))
|
|
5684
|
+
return { status: 400, body: { error: "invalid run id" } };
|
|
5685
|
+
if (typeof answer !== "string" || answer.trim() === "")
|
|
5686
|
+
return { status: 400, body: { error: "answer must be a non-empty string" } };
|
|
5687
|
+
if (answer.length > MAX_ANSWER)
|
|
5688
|
+
return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
|
|
5689
|
+
const d = join2(dir, source);
|
|
5690
|
+
mkdirSync(d, { recursive: true, mode: 448 });
|
|
5691
|
+
const path = join2(d, `${run}.answer.txt`);
|
|
5692
|
+
writeFileSync(path, `${answer.trim()}
|
|
5693
|
+
`, { mode: 384 });
|
|
5694
|
+
chmodSync(path, 384);
|
|
5695
|
+
return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5661
5698
|
// src/server/runs.ts
|
|
5662
5699
|
var str = (x) => typeof x === "string" ? x : null;
|
|
5663
5700
|
function loadEvents(db, sourceId, runId) {
|
|
@@ -5680,6 +5717,8 @@ function rebuildRun(db, sourceId, runId) {
|
|
|
5680
5717
|
taskSource: str(sd.task_source),
|
|
5681
5718
|
provider: str(sd.provider),
|
|
5682
5719
|
model: str(sd.model),
|
|
5720
|
+
groupId: str(sd.group_id),
|
|
5721
|
+
unitId: str(sd.unit_id),
|
|
5683
5722
|
startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null,
|
|
5684
5723
|
endedAt: f.run.completed?.ts ?? null,
|
|
5685
5724
|
verdict: f.run.verdict,
|
|
@@ -5704,6 +5743,8 @@ var parseRun = (r) => ({
|
|
|
5704
5743
|
origin_repo: r.originRepo,
|
|
5705
5744
|
issue_ref: r.issueRef,
|
|
5706
5745
|
task_source: r.taskSource,
|
|
5746
|
+
group_id: r.groupId,
|
|
5747
|
+
unit_id: r.unitId,
|
|
5707
5748
|
provider: r.provider,
|
|
5708
5749
|
model: r.model,
|
|
5709
5750
|
started_at: r.startedAt,
|
|
@@ -5763,7 +5804,7 @@ var elapsedS = (r) => {
|
|
|
5763
5804
|
function listRuns(db, q) {
|
|
5764
5805
|
const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
|
|
5765
5806
|
const offset = Math.max(Number.parseInt(q.cursor ?? "0", 10) || 0, 0);
|
|
5766
|
-
const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
|
|
5807
|
+
const where = and(q.verdict ? eq(runs.verdict, q.verdict) : undefined, q.repo ? eq(runs.originRepo, q.repo) : undefined, q.group_id ? eq(runs.groupId, q.group_id) : undefined, q.since ? gte(runs.startedAt, q.since) : undefined, q.until ? lt(runs.startedAt, q.until) : undefined);
|
|
5767
5808
|
const total = db.select({ n: sql`count(*)` }).from(runs).where(where).get()?.n ?? 0;
|
|
5768
5809
|
const rows = db.select().from(runs).where(where).orderBy(desc(runs.startedAt), asc(runs.runId)).limit(limit).offset(offset).all();
|
|
5769
5810
|
return { runs: rows.map((r) => withLive(db, r)), total, next_cursor: offset + rows.length < total ? String(offset + rows.length) : null };
|
|
@@ -5792,6 +5833,7 @@ function runDetail(db, sourceId, runId) {
|
|
|
5792
5833
|
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
5793
5834
|
return {
|
|
5794
5835
|
...withLive(db, r),
|
|
5836
|
+
blocked_question: blockedQuestion(evs, r.verdict),
|
|
5795
5837
|
stages,
|
|
5796
5838
|
stages_completed: f.completed,
|
|
5797
5839
|
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|
|
@@ -5846,14 +5888,64 @@ function ingest(db, body) {
|
|
|
5846
5888
|
return conflict > 0 ? { status: 409, body: { ...out, error: "conflicting content for an existing event id" } } : { status: 200, body: out };
|
|
5847
5889
|
}
|
|
5848
5890
|
|
|
5891
|
+
// src/server/auth.ts
|
|
5892
|
+
import { createHash as createHash2, timingSafeEqual } from "crypto";
|
|
5893
|
+
var LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
5894
|
+
function isLoopbackHost(host) {
|
|
5895
|
+
return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
|
|
5896
|
+
}
|
|
5897
|
+
function tokenMatches(header, token) {
|
|
5898
|
+
const m = /^Bearer (.+)$/i.exec(header ?? "");
|
|
5899
|
+
if (!m)
|
|
5900
|
+
return false;
|
|
5901
|
+
const a = createHash2("sha256").update(m[1] ?? "").digest();
|
|
5902
|
+
const b = createHash2("sha256").update(token).digest();
|
|
5903
|
+
return timingSafeEqual(a, b);
|
|
5904
|
+
}
|
|
5905
|
+
function bindRefusal(env) {
|
|
5906
|
+
const host = env.LOKI_CONTROL_HOST;
|
|
5907
|
+
if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
|
|
5908
|
+
return null;
|
|
5909
|
+
if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1")
|
|
5910
|
+
return null;
|
|
5911
|
+
return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
|
|
5912
|
+
}
|
|
5913
|
+
function insecureBindWarning(env) {
|
|
5914
|
+
const host = env.LOKI_CONTROL_HOST;
|
|
5915
|
+
if (!host || LOOPBACK_BINDS.has(host.toLowerCase()))
|
|
5916
|
+
return null;
|
|
5917
|
+
if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1")
|
|
5918
|
+
return null;
|
|
5919
|
+
return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
|
|
5920
|
+
}
|
|
5921
|
+
function hostGuard() {
|
|
5922
|
+
return async (c, next) => {
|
|
5923
|
+
if (!isLoopbackHost(c.req.header("host")))
|
|
5924
|
+
return c.json({ error: "host not allowed" }, 403);
|
|
5925
|
+
await next();
|
|
5926
|
+
};
|
|
5927
|
+
}
|
|
5928
|
+
function tokenGuard(token) {
|
|
5929
|
+
return async (c, next) => {
|
|
5930
|
+
if (!tokenMatches(c.req.header("authorization"), token))
|
|
5931
|
+
return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
|
|
5932
|
+
await next();
|
|
5933
|
+
};
|
|
5934
|
+
}
|
|
5935
|
+
|
|
5849
5936
|
// src/server/app.ts
|
|
5850
5937
|
var MAX_BODY = 1e6;
|
|
5851
|
-
var defaultUiDir = () => [
|
|
5938
|
+
var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
|
|
5852
5939
|
function createApp(opts) {
|
|
5853
5940
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
5854
5941
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
5855
5942
|
let ready = true;
|
|
5943
|
+
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
5856
5944
|
const app = new Hono3;
|
|
5945
|
+
if (opts.loopbackOnly)
|
|
5946
|
+
app.use("*", hostGuard());
|
|
5947
|
+
if (opts.token)
|
|
5948
|
+
app.use("/v1/*", tokenGuard(opts.token));
|
|
5857
5949
|
app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
|
|
5858
5950
|
app.get("/ready", (c) => {
|
|
5859
5951
|
try {
|
|
@@ -5878,13 +5970,34 @@ function createApp(opts) {
|
|
|
5878
5970
|
});
|
|
5879
5971
|
app.get("/v1/runs", (c) => {
|
|
5880
5972
|
const q = c.req.query();
|
|
5881
|
-
return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
|
|
5973
|
+
return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
|
|
5882
5974
|
});
|
|
5883
5975
|
const detail = (c, source, run) => {
|
|
5884
5976
|
const d = runDetail(db, source, run);
|
|
5885
5977
|
return d ? c.json(d) : c.json({ error: "run not found" }, 404);
|
|
5886
5978
|
};
|
|
5887
5979
|
app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
|
|
5980
|
+
app.post("/v1/runs/:source/:run/answer", async (c) => {
|
|
5981
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
5982
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json"))
|
|
5983
|
+
return c.json({ error: "content-type must be application/json" }, 400);
|
|
5984
|
+
const text2 = await c.req.text();
|
|
5985
|
+
if (text2.length > 20000)
|
|
5986
|
+
return c.json({ error: "body too large" }, 413);
|
|
5987
|
+
let body;
|
|
5988
|
+
try {
|
|
5989
|
+
body = JSON.parse(text2);
|
|
5990
|
+
} catch {
|
|
5991
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
5992
|
+
}
|
|
5993
|
+
const d = runDetail(db, source, run);
|
|
5994
|
+
if (!d)
|
|
5995
|
+
return c.json({ error: "run not found" }, 404);
|
|
5996
|
+
if (!d.blocked_question)
|
|
5997
|
+
return c.json({ error: "run is not blocked on a question" }, 409);
|
|
5998
|
+
const r = writeAnswer(answerDir, source, run, body?.answer);
|
|
5999
|
+
return c.json(r.body, r.status);
|
|
6000
|
+
});
|
|
5888
6001
|
app.get("/v1/runs/:id", (c) => {
|
|
5889
6002
|
const id = c.req.param("id");
|
|
5890
6003
|
const i = id.indexOf(":");
|
|
@@ -5900,14 +6013,23 @@ function createApp(opts) {
|
|
|
5900
6013
|
return new Response(Bun.file(f));
|
|
5901
6014
|
if (/\.[a-z0-9]+$/i.test(p))
|
|
5902
6015
|
return c.json({ error: "not found" }, 404);
|
|
5903
|
-
return new Response(Bun.file(
|
|
6016
|
+
return new Response(Bun.file(join3(root, "index.html")));
|
|
5904
6017
|
});
|
|
5905
6018
|
return { app, db, close: () => sqlite.close() };
|
|
5906
6019
|
}
|
|
5907
6020
|
|
|
5908
6021
|
// src/server/serve.ts
|
|
5909
|
-
var
|
|
5910
|
-
|
|
6022
|
+
var refusal = bindRefusal(process.env);
|
|
6023
|
+
if (refusal) {
|
|
6024
|
+
console.error(refusal);
|
|
6025
|
+
process.exit(2);
|
|
6026
|
+
}
|
|
6027
|
+
var warning = insecureBindWarning(process.env);
|
|
6028
|
+
if (warning)
|
|
6029
|
+
console.error(warning);
|
|
6030
|
+
var bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
|
|
6031
|
+
var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
|
|
6032
|
+
var serve = (port) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
|
|
5911
6033
|
var s;
|
|
5912
6034
|
try {
|
|
5913
6035
|
s = serve(Number(process.env.PORT ?? 0));
|
|
@@ -47,8 +47,11 @@ export const runs = sqliteTable("runs", {
|
|
|
47
47
|
lastEventAt: text("last_event_at"),
|
|
48
48
|
tampered: integer("tampered").notNull(),
|
|
49
49
|
conflict: integer("conflict").notNull().default(0),
|
|
50
|
+
groupId: text("group_id"),
|
|
51
|
+
unitId: text("unit_id"),
|
|
50
52
|
}, (t) => [
|
|
51
53
|
primaryKey({ columns: [t.sourceId, t.runId] }),
|
|
52
54
|
index("runs_started").on(t.startedAt),
|
|
53
55
|
index("runs_verdict").on(t.verdict),
|
|
56
|
+
index("runs_group").on(t.groupId),
|
|
54
57
|
]);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// BLOCKED answer: a human reply to a run's one question, written to a file the operator resumes from.
|
|
2
|
+
// File: <answerDir>/<source>/<run>.answer.txt (default ~/.loki/control/answers, override LOKI_CONTROL_ANSWER_DIR).
|
|
3
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
7
|
+
|
|
8
|
+
export const MAX_ANSWER = 4000;
|
|
9
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
|
|
11
|
+
export const defaultAnswerDir = (env: NodeJS.ProcessEnv = process.env): string =>
|
|
12
|
+
env.LOKI_CONTROL_ANSWER_DIR || join(env.HOME || homedir(), ".loki", "control", "answers");
|
|
13
|
+
|
|
14
|
+
/** The question a BLOCKED (spec conflict) run is waiting on, or null when it is not blocked. */
|
|
15
|
+
export function blockedQuestion(evs: EventEnvelope[], verdict: string | null): string | null {
|
|
16
|
+
if (verdict !== "SPEC_CONFLICT") return null;
|
|
17
|
+
const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
|
|
18
|
+
return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AnswerResult = { status: 200; body: { ok: true; path: string; resume: string } } | { status: 400 | 413; body: { error: string } };
|
|
22
|
+
|
|
23
|
+
export function writeAnswer(dir: string, source: string, run: string, answer: unknown): AnswerResult {
|
|
24
|
+
if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes("..")) return { status: 400, body: { error: "invalid run id" } };
|
|
25
|
+
if (typeof answer !== "string" || answer.trim() === "") return { status: 400, body: { error: "answer must be a non-empty string" } };
|
|
26
|
+
if (answer.length > MAX_ANSWER) return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
|
|
27
|
+
const d = join(dir, source);
|
|
28
|
+
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
29
|
+
const path = join(d, `${run}.answer.txt`);
|
|
30
|
+
writeFileSync(path, `${answer.trim()}\n`, { mode: 0o600 });
|
|
31
|
+
chmodSync(path, 0o600);
|
|
32
|
+
return { status: 200, body: { ok: true, path, resume: `loki answer ${run}` } };
|
|
33
|
+
}
|
|
@@ -3,7 +3,9 @@ import { join, resolve, sep } from "node:path";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { openDb } from "../db/migrate.ts";
|
|
5
5
|
import { ingest } from "./ingest.ts";
|
|
6
|
+
import { defaultAnswerDir, writeAnswer } from "./answer.ts";
|
|
6
7
|
import { listRuns, runDetail } from "./runs.ts";
|
|
8
|
+
import { hostGuard, tokenGuard } from "./auth.ts";
|
|
7
9
|
|
|
8
10
|
const MAX_BODY = 1_000_000;
|
|
9
11
|
|
|
@@ -11,11 +13,15 @@ const MAX_BODY = 1_000_000;
|
|
|
11
13
|
const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
|
|
12
14
|
|
|
13
15
|
/** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
|
|
14
|
-
export function createApp(opts: { dbPath: string; uiDir?: string }) {
|
|
16
|
+
export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string; token?: string; loopbackOnly?: boolean }) {
|
|
15
17
|
const uiDir = opts.uiDir ?? defaultUiDir();
|
|
16
18
|
const { db, sqlite } = openDb(opts.dbPath);
|
|
17
19
|
let ready = true;
|
|
20
|
+
const answerDir = opts.answerDir ?? defaultAnswerDir();
|
|
18
21
|
const app = new Hono();
|
|
22
|
+
// token: bearer required on /v1/*. loopbackOnly: reject a non-loopback Host (DNS rebinding). Both off by default; serve.ts sets them from env.
|
|
23
|
+
if (opts.loopbackOnly) app.use("*", hostGuard());
|
|
24
|
+
if (opts.token) app.use("/v1/*", tokenGuard(opts.token));
|
|
19
25
|
|
|
20
26
|
app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
|
|
21
27
|
app.get("/ready", (c) => {
|
|
@@ -34,13 +40,27 @@ export function createApp(opts: { dbPath: string; uiDir?: string }) {
|
|
|
34
40
|
|
|
35
41
|
app.get("/v1/runs", (c) => {
|
|
36
42
|
const q = c.req.query();
|
|
37
|
-
return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
|
|
43
|
+
return c.json(listRuns(db, { verdict: q.verdict, repo: q.repo, since: q.since, until: q.until, group_id: q.group_id, limit: q.limit ? Number(q.limit) : undefined, cursor: q.cursor }));
|
|
38
44
|
});
|
|
39
45
|
const detail = (c: { json: (b: unknown, s?: 404) => Response }, source: string, run: string) => {
|
|
40
46
|
const d = runDetail(db, source, run);
|
|
41
47
|
return d ? c.json(d) : c.json({ error: "run not found" }, 404);
|
|
42
48
|
};
|
|
43
49
|
app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
|
|
50
|
+
// BLOCKED answer: only for a run blocked on a question; JSON content type required (blocks cross-site form posts).
|
|
51
|
+
app.post("/v1/runs/:source/:run/answer", async (c) => {
|
|
52
|
+
const source = c.req.param("source"), run = c.req.param("run");
|
|
53
|
+
if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 400);
|
|
54
|
+
const text = await c.req.text();
|
|
55
|
+
if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
|
|
56
|
+
let body: { answer?: unknown } | null;
|
|
57
|
+
try { body = JSON.parse(text) as { answer?: unknown } | null; } catch { return c.json({ error: "invalid JSON" }, 400); }
|
|
58
|
+
const d = runDetail(db, source, run);
|
|
59
|
+
if (!d) return c.json({ error: "run not found" }, 404);
|
|
60
|
+
if (!d.blocked_question) return c.json({ error: "run is not blocked on a question" }, 409);
|
|
61
|
+
const r = writeAnswer(answerDir, source, run, body?.answer);
|
|
62
|
+
return c.json(r.body, r.status);
|
|
63
|
+
});
|
|
44
64
|
// :id is `source:run` (run ids never contain a colon)
|
|
45
65
|
app.get("/v1/runs/:id", (c) => {
|
|
46
66
|
const id = c.req.param("id");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Control Plane auth: bearer token on /v1, Host allowlist on loopback (DNS rebinding), bind refusal without a token.
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import type { MiddlewareHandler } from "hono";
|
|
4
|
+
|
|
5
|
+
const LOOPBACK_BINDS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
6
|
+
|
|
7
|
+
/** True when a Host header value (optional port) names this machine's loopback. Strict: nothing may follow the name or port. */
|
|
8
|
+
export function isLoopbackHost(host: string | undefined | null): boolean {
|
|
9
|
+
return !!host && /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i.test(host);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Constant-time check of an Authorization header against the token. Hashing first makes the compare length-safe. */
|
|
13
|
+
export function tokenMatches(header: string | undefined | null, token: string): boolean {
|
|
14
|
+
const m = /^Bearer (.+)$/i.exec(header ?? "");
|
|
15
|
+
if (!m) return false;
|
|
16
|
+
const a = createHash("sha256").update(m[1] ?? "").digest();
|
|
17
|
+
const b = createHash("sha256").update(token).digest();
|
|
18
|
+
return timingSafeEqual(a, b);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Message to print before exiting 2 when the env asks for a non-loopback bind with no token, else null. */
|
|
22
|
+
export function bindRefusal(env: Record<string, string | undefined>): string | null {
|
|
23
|
+
const host = env.LOKI_CONTROL_HOST;
|
|
24
|
+
if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
|
|
25
|
+
if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND === "1") return null;
|
|
26
|
+
return `loki-control: refusing to listen on ${host} without LOKI_CONTROL_TOKEN. Set LOKI_CONTROL_TOKEN, or LOKI_CONTROL_ALLOW_INSECURE_BIND=1 to accept an unauthenticated non-loopback bind.`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Stderr warning when ALLOW_INSECURE_BIND is what permits a non-loopback bind with no token, else null. Never includes a token. */
|
|
30
|
+
export function insecureBindWarning(env: Record<string, string | undefined>): string | null {
|
|
31
|
+
const host = env.LOKI_CONTROL_HOST;
|
|
32
|
+
if (!host || LOOPBACK_BINDS.has(host.toLowerCase())) return null;
|
|
33
|
+
if (env.LOKI_CONTROL_TOKEN || env.LOKI_CONTROL_ALLOW_INSECURE_BIND !== "1") return null;
|
|
34
|
+
return `loki-control: WARNING listening on ${host} with no LOKI_CONTROL_TOKEN (LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Anyone who can reach this port can read and write runs.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Host allowlist guard (apply on "*"; /health and /ready still need the Host check on loopback). */
|
|
38
|
+
export function hostGuard(): MiddlewareHandler {
|
|
39
|
+
return async (c, next) => {
|
|
40
|
+
if (!isLoopbackHost(c.req.header("host"))) return c.json({ error: "host not allowed" }, 403);
|
|
41
|
+
await next();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Bearer guard. Register it with app.use("/v1/*", ...) so Hono's decoded route matching decides coverage, never the raw URL. */
|
|
46
|
+
export function tokenGuard(token: string): MiddlewareHandler {
|
|
47
|
+
return async (c, next) => {
|
|
48
|
+
if (!tokenMatches(c.req.header("authorization"), token)) return c.json({ error: "unauthorized" }, 401, { "www-authenticate": "Bearer" });
|
|
49
|
+
await next();
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -3,6 +3,7 @@ import { fold, partialCost } from "../../../../loki-ts/src/engine10/events.ts";
|
|
|
3
3
|
import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
|
|
4
4
|
import type { Db } from "../db/migrate.ts";
|
|
5
5
|
import { events, runs } from "../db/schema.ts";
|
|
6
|
+
import { blockedQuestion } from "./answer.ts";
|
|
6
7
|
|
|
7
8
|
const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
|
|
8
9
|
|
|
@@ -23,7 +24,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
|
|
|
23
24
|
const row = {
|
|
24
25
|
sourceId, runId,
|
|
25
26
|
originRepo: str(sd.origin_repo), issueRef: str(sd.issue_ref), taskSource: str(sd.task_source),
|
|
26
|
-
provider: str(sd.provider), model: str(sd.model),
|
|
27
|
+
provider: str(sd.provider), model: str(sd.model), groupId: str(sd.group_id), unitId: str(sd.unit_id),
|
|
27
28
|
startedAt: f.run.started?.ts ?? evs[0]?.ts ?? null, endedAt: f.run.completed?.ts ?? null,
|
|
28
29
|
verdict: f.run.verdict,
|
|
29
30
|
prUrl: str(done.pr_url) ?? str(pr?.url), prDraft: typeof pr?.draft === "boolean" ? Number(pr.draft) : null,
|
|
@@ -38,7 +39,7 @@ export function rebuildRun(db: Db, sourceId: string, runId: string): void {
|
|
|
38
39
|
|
|
39
40
|
const parseRun = (r: typeof runs.$inferSelect) => ({
|
|
40
41
|
source_id: r.sourceId, run_id: r.runId, origin_repo: r.originRepo, issue_ref: r.issueRef, task_source: r.taskSource,
|
|
41
|
-
provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
|
|
42
|
+
group_id: r.groupId, unit_id: r.unitId, provider: r.provider, model: r.model, started_at: r.startedAt, ended_at: r.endedAt, verdict: r.verdict,
|
|
42
43
|
pr_url: r.prUrl, pr_draft: r.prDraft === null ? null : r.prDraft === 1,
|
|
43
44
|
cost_usd: r.costUsd, partial_usd: r.partialUsd, measured_sessions: r.measuredSessions, total_sessions: r.totalSessions,
|
|
44
45
|
input_tokens: r.inputTokens, output_tokens: r.outputTokens, wall_s: r.wallS, last_seq: r.lastSeq,
|
|
@@ -74,7 +75,7 @@ const elapsedS = (r: typeof runs.$inferSelect): number | null => {
|
|
|
74
75
|
return Math.max(0, (end - Date.parse(r.startedAt)) / 1000);
|
|
75
76
|
};
|
|
76
77
|
|
|
77
|
-
export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; limit?: number; cursor?: string }
|
|
78
|
+
export interface ListQuery { verdict?: string; repo?: string; since?: string; until?: string; group_id?: string; limit?: number; cursor?: string }
|
|
78
79
|
|
|
79
80
|
export function listRuns(db: Db, q: ListQuery) {
|
|
80
81
|
const limit = Math.min(Math.max(q.limit ?? 50, 1), 200);
|
|
@@ -82,6 +83,7 @@ export function listRuns(db: Db, q: ListQuery) {
|
|
|
82
83
|
const where = and(
|
|
83
84
|
q.verdict ? eq(runs.verdict, q.verdict) : undefined,
|
|
84
85
|
q.repo ? eq(runs.originRepo, q.repo) : undefined,
|
|
86
|
+
q.group_id ? eq(runs.groupId, q.group_id) : undefined,
|
|
85
87
|
q.since ? gte(runs.startedAt, q.since) : undefined,
|
|
86
88
|
q.until ? lt(runs.startedAt, q.until) : undefined,
|
|
87
89
|
);
|
|
@@ -108,6 +110,7 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
|
|
|
108
110
|
const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
|
|
109
111
|
return {
|
|
110
112
|
...withLive(db, r),
|
|
113
|
+
blocked_question: blockedQuestion(evs, r.verdict),
|
|
111
114
|
stages,
|
|
112
115
|
stages_completed: f.completed,
|
|
113
116
|
receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
// Entry for `loki control serve` (bundled to dist/server.js). Config comes from env: PORT, LOKI_CONTROL_DB.
|
|
2
|
-
// Binds loopback
|
|
2
|
+
// Binds loopback unless LOKI_CONTROL_HOST is set (the container image sets 0.0.0.0). LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
|
|
3
3
|
// retries on any free port when PORT is taken; the URL printed below is always the real one.
|
|
4
4
|
import { createApp } from "./app.ts";
|
|
5
|
+
import { bindRefusal, insecureBindWarning, isLoopbackHost } from "./auth.ts";
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
const
|
|
7
|
+
// A non-loopback bind needs LOKI_CONTROL_TOKEN (or LOKI_CONTROL_ALLOW_INSECURE_BIND=1). Refuse before opening the DB or binding.
|
|
8
|
+
const refusal = bindRefusal(process.env);
|
|
9
|
+
if (refusal) {
|
|
10
|
+
console.error(refusal);
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
const warning = insecureBindWarning(process.env);
|
|
14
|
+
if (warning) console.error(warning);
|
|
15
|
+
const bindHost = process.env.LOKI_CONTROL_HOST || "127.0.0.1";
|
|
16
|
+
const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db", token: process.env.LOKI_CONTROL_TOKEN || undefined, loopbackOnly: isLoopbackHost(bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost) });
|
|
17
|
+
const serve = (port: number) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
|
|
8
18
|
let s: ReturnType<typeof serve>;
|
|
9
19
|
try {
|
|
10
20
|
s = serve(Number(process.env.PORT ?? 0));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// C2 CP-DEFAULT: find a running local Control Plane (docs/v10/CONTROL-PLANE.md section 6). Reads
|
|
2
|
+
// ~/.loki/control/instance.json; returns its url only when the pid is alive and /health answers service=loki-control
|
|
3
|
+
// within 300 ms. Never starts a server, never throws. LOKI_CONTROL=0 disables discovery.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const instancePath = (env: NodeJS.ProcessEnv): string => join(env.HOME || homedir(), ".loki", "control", "instance.json");
|
|
9
|
+
|
|
10
|
+
export interface DiscoverOpts { fetchImpl?: typeof fetch | undefined; alive?: ((pid: number) => boolean) | undefined }
|
|
11
|
+
|
|
12
|
+
const isAlive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
13
|
+
|
|
14
|
+
export function isLoopbackHttp(u: string): boolean {
|
|
15
|
+
try { const p = new URL(u); return p.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(p.hostname); } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function discoverControlUrl(env: NodeJS.ProcessEnv, o: DiscoverOpts = {}): Promise<string | null> {
|
|
19
|
+
if (env.LOKI_CONTROL === "0") return null;
|
|
20
|
+
if (env.LOKI_CONTROL_URL) return env.LOKI_CONTROL_URL;
|
|
21
|
+
try {
|
|
22
|
+
const inst = JSON.parse(readFileSync(instancePath(env), "utf8")) as { pid?: unknown; url?: unknown };
|
|
23
|
+
if (!Number.isInteger(inst.pid) || typeof inst.url !== "string" || !(o.alive ?? isAlive)(inst.pid as number)) return null;
|
|
24
|
+
if (!isLoopbackHttp(inst.url)) return null; // a planted instance.json must never redirect events off this machine
|
|
25
|
+
const url = inst.url.replace(/\/+$/, "");
|
|
26
|
+
const h = (await (await (o.fetchImpl ?? fetch)(`${url}/health`, { signal: AbortSignal.timeout(300) })).json()) as { service?: string };
|
|
27
|
+
return h.service === "loki-control" ? url : null;
|
|
28
|
+
} catch { return null; }
|
|
29
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-48{width:12rem}.w-full{width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-500\/15{background-color:#f59e0b26}.bg-emerald-500\/15{background-color:#10b98126}.bg-red-500\/15{background-color:#ef444426}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-amber-500\/30{--tw-ring-color: rgb(245 158 11 / .3)}.ring-emerald-500\/30{--tw-ring-color: rgb(16 185 129 / .3)}.ring-red-500\/30{--tw-ring-color: rgb(239 68 68 / .3)}.ring-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.hover\:underline:hover{text-decoration-line:underline}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-6{margin-top:1.5rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-48{width:12rem}.w-full{width:100%}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.list-disc{list-style-type:disc}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-1{row-gap:.25rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.border{border-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-slate-200{--tw-border-opacity: 1;border-color:rgb(226 232 240 / var(--tw-border-opacity, 1))}.border-slate-300{--tw-border-opacity: 1;border-color:rgb(203 213 225 / var(--tw-border-opacity, 1))}.bg-amber-500\/15{background-color:#f59e0b26}.bg-emerald-500\/15{background-color:#10b98126}.bg-red-500\/15{background-color:#ef444426}.bg-sky-600{--tw-bg-opacity: 1;background-color:rgb(2 132 199 / var(--tw-bg-opacity, 1))}.bg-slate-100{--tw-bg-opacity: 1;background-color:rgb(241 245 249 / var(--tw-bg-opacity, 1))}.bg-slate-200{--tw-bg-opacity: 1;background-color:rgb(226 232 240 / var(--tw-bg-opacity, 1))}.bg-slate-500\/15{background-color:#64748b26}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-sky-600{--tw-text-opacity: 1;color:rgb(2 132 199 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-slate-900{--tw-text-opacity: 1;color:rgb(15 23 42 / var(--tw-text-opacity, 1))}.text-violet-700{--tw-text-opacity: 1;color:rgb(109 40 217 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.ring-1{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-amber-500\/30{--tw-ring-color: rgb(245 158 11 / .3)}.ring-emerald-500\/30{--tw-ring-color: rgb(16 185 129 / .3)}.ring-red-500\/30{--tw-ring-color: rgb(239 68 68 / .3)}.ring-slate-500\/30{--tw-ring-color: rgb(100 116 139 / .3)}.ring-violet-500\/30{--tw-ring-color: rgb(139 92 246 / .3)}.hover\:underline:hover{text-decoration-line:underline}.disabled\:opacity-50:disabled{opacity:.5}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:border-slate-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(30 41 59 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(15 23 42 / var(--tw-bg-opacity, 1))}.dark\:bg-slate-950:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(2 6 23 / var(--tw-bg-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}.dark\:text-sky-400:is(.dark *){--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.dark\:text-slate-100:is(.dark *){--tw-text-opacity: 1;color:rgb(241 245 249 / var(--tw-text-opacity, 1))}.dark\:text-slate-300:is(.dark *){--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.dark\:text-slate-400:is(.dark *){--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.dark\:text-violet-300:is(.dark *){--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}
|