loki-mode 10.7.0 → 10.8.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.
Files changed (130) hide show
  1. package/README.md +1 -0
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/loki +4 -1
  5. package/autonomy/run.sh +9 -5
  6. package/dashboard/__init__.py +1 -1
  7. package/dashboard/server.py +6 -8
  8. package/docs/INSTALLATION.md +1 -1
  9. package/docs/v10/CONTROL-PLANE.md +7 -0
  10. package/docs/v10/CP-ENTERPRISE-UI.md +263 -2
  11. package/docs/v10/DECISIONS.md +41 -0
  12. package/docs/v10/ENGINE-LAWS.md +155 -0
  13. package/docs/v10/FAILURE-CLASSES.md +166 -0
  14. package/docs/v10/GUIDE.md +4 -2
  15. package/docs/v10/L0-WAVE1.md +81 -0
  16. package/docs/v10/METRICS.md +6 -0
  17. package/docs/v10/MORNING-BRIEF-1003.md +11 -25
  18. package/docs/v10/OPERATING-MODEL.md +1 -0
  19. package/docs/v10/PROGRESS.md +28 -0
  20. package/loki-ts/data/model-pricing.json +6 -6
  21. package/loki-ts/dist/loki.js +851 -837
  22. package/loki-ts/package.json +2 -1
  23. package/mcp/__init__.py +1 -1
  24. package/package.json +2 -2
  25. package/packages/control-plane/dist/server.js +17103 -5411
  26. package/packages/control-plane/drizzle/0002_tiresome_fixer.sql +7 -0
  27. package/packages/control-plane/drizzle/0003_integrity.sql +4 -0
  28. package/packages/control-plane/drizzle/0004_cpe03_local_repos_actions.sql +17 -0
  29. package/packages/control-plane/drizzle/meta/0002_snapshot.json +409 -0
  30. package/packages/control-plane/drizzle/meta/0003_snapshot.json +437 -0
  31. package/packages/control-plane/drizzle/meta/0004_snapshot.json +542 -0
  32. package/packages/control-plane/drizzle/meta/_journal.json +22 -1
  33. package/packages/control-plane/package.json +4 -1
  34. package/packages/control-plane/src/db/migrate.ts +6 -2
  35. package/packages/control-plane/src/db/prune.ts +91 -0
  36. package/packages/control-plane/src/db/schema.ts +32 -0
  37. package/packages/control-plane/src/server/app.ts +30 -22
  38. package/packages/control-plane/src/server/audit.ts +15 -0
  39. package/packages/control-plane/src/server/auth.ts +7 -1
  40. package/packages/control-plane/src/server/integrity.ts +147 -0
  41. package/packages/control-plane/src/server/legacy/routes-metrics.ts +18 -0
  42. package/packages/control-plane/src/server/legacy/routes.ts +237 -0
  43. package/packages/control-plane/src/server/legacy/shim.ts +195 -0
  44. package/packages/control-plane/src/server/repos.ts +27 -0
  45. package/packages/control-plane/src/server/routes/artifacts.ts +104 -0
  46. package/packages/control-plane/src/server/routes/audit.ts +17 -0
  47. package/packages/control-plane/src/server/routes/config.ts +319 -0
  48. package/packages/control-plane/src/server/routes/control.ts +75 -0
  49. package/packages/control-plane/src/server/routes/cost.ts +70 -0
  50. package/packages/control-plane/src/server/routes/cost_ledger.ts +210 -0
  51. package/packages/control-plane/src/server/routes/fleet.ts +101 -0
  52. package/packages/control-plane/src/server/routes/index.ts +68 -0
  53. package/packages/control-plane/src/server/routes/integrations.ts +123 -0
  54. package/packages/control-plane/src/server/routes/merge_risk.ts +209 -0
  55. package/packages/control-plane/src/server/routes/metrics.ts +93 -0
  56. package/packages/control-plane/src/server/routes/notify.ts +48 -0
  57. package/packages/control-plane/src/server/routes/providers.ts +111 -0
  58. package/packages/control-plane/src/server/routes/start.ts +39 -0
  59. package/packages/control-plane/src/server/routes/stats.ts +76 -0
  60. package/packages/control-plane/src/server/routes/stream.ts +85 -0
  61. package/packages/control-plane/src/server/routes/verify.ts +66 -0
  62. package/packages/control-plane/src/server/runs.ts +46 -4
  63. package/packages/control-plane/src/server/spawn.ts +79 -0
  64. package/packages/control-plane/src/shipper/discover.ts +50 -3
  65. package/packages/control-plane/src/shipper/ship.ts +15 -0
  66. package/packages/control-plane/src/shipper/watch.ts +3 -1
  67. package/packages/control-plane/ui/dist/assets/index-BQbFv65M.css +1 -0
  68. package/packages/control-plane/ui/dist/assets/index-Dbhu244h.js +155 -0
  69. package/packages/control-plane/ui/dist/index.html +2 -2
  70. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  71. package/web-app/dist/assets/{AdminPage-BQPb5rc5.js → AdminPage-DDDvUz0N.js} +1 -1
  72. package/web-app/dist/assets/{Avatar-Cttco6FC.js → Avatar-Dcdz46i6.js} +1 -1
  73. package/web-app/dist/assets/{Badge-CPb6kpbH.js → Badge-BB24cCpK.js} +1 -1
  74. package/web-app/dist/assets/{Button-caTL28a4.js → Button-D0-DmPZL.js} +1 -1
  75. package/web-app/dist/assets/{CockpitPage-bEbLx6ph.js → CockpitPage-CdjpU2JW.js} +1 -1
  76. package/web-app/dist/assets/{ComparePage-DNtJSij-.js → ComparePage-DT5U0y0d.js} +1 -1
  77. package/web-app/dist/assets/{ErrorBoundary-BrgC3K44.js → ErrorBoundary-lEHcl0EO.js} +1 -1
  78. package/web-app/dist/assets/{EvidenceReceiptPanel-Q-mSrUUQ.js → EvidenceReceiptPanel-GpnWvdCr.js} +1 -1
  79. package/web-app/dist/assets/{GitHubIssuesPanel-DbmYl_Er.js → GitHubIssuesPanel-C7VOh1B7.js} +1 -1
  80. package/web-app/dist/assets/{GitHubPRsPanel-C6PCHM4B.js → GitHubPRsPanel-G_kOuINv.js} +1 -1
  81. package/web-app/dist/assets/{HomePage-7zsL_k_e.js → HomePage-PqRisOny.js} +2 -2
  82. package/web-app/dist/assets/{LoginPage-Cl4ON8RQ.js → LoginPage-DU7pFAkf.js} +1 -1
  83. package/web-app/dist/assets/{MagicPage-BNEAhTqH.js → MagicPage-C5JWIWMk.js} +1 -1
  84. package/web-app/dist/assets/{MetricsPage-DyBH-88q.js → MetricsPage-CbIGS_qo.js} +1 -1
  85. package/web-app/dist/assets/{NotFoundPage-CIVr21yL.js → NotFoundPage-vU1pvdSt.js} +1 -1
  86. package/web-app/dist/assets/{ProjectPage-BMeVYocF.js → ProjectPage-SsixIPPv.js} +3 -3
  87. package/web-app/dist/assets/{ProjectsPage-D-H2WRVU.js → ProjectsPage-BfQKSR9k.js} +1 -1
  88. package/web-app/dist/assets/{SettingsPage-o0kR9RAt.js → SettingsPage-B8YfXAom.js} +1 -1
  89. package/web-app/dist/assets/{ShowcasePage-DtfMDMjp.js → ShowcasePage-Ba407f2-.js} +1 -1
  90. package/web-app/dist/assets/{SystemSettingsPage-CkXdpZ_T.js → SystemSettingsPage-DzNQhhBS.js} +1 -1
  91. package/web-app/dist/assets/{TeamsPage-Cu5H9cQ0.js → TeamsPage-tqUnpZ15.js} +1 -1
  92. package/web-app/dist/assets/{TemplatesPage-3wdbVwil.js → TemplatesPage-ClB5EpI3.js} +1 -1
  93. package/web-app/dist/assets/{TerminalOutput-CWn301xh.js → TerminalOutput-CBvpYfEu.js} +1 -1
  94. package/web-app/dist/assets/{activity-YGaAt4zJ.js → activity-C1s0L_X9.js} +1 -1
  95. package/web-app/dist/assets/{bell-C28q0GO7.js → bell-DMEiXr73.js} +1 -1
  96. package/web-app/dist/assets/{bot-DLcQGI92.js → bot-BF_xIOxu.js} +1 -1
  97. package/web-app/dist/assets/{check-w0sBNlcm.js → check-Dv1Ynvrs.js} +1 -1
  98. package/web-app/dist/assets/{chevron-left-W7O0vC_m.js → chevron-left-Ddhsod7S.js} +1 -1
  99. package/web-app/dist/assets/{circle-alert-Cxjq9fxB.js → circle-alert-OATHUkBZ.js} +1 -1
  100. package/web-app/dist/assets/{clock-C8EqzmNl.js → clock-C1Ltva07.js} +1 -1
  101. package/web-app/dist/assets/{cloud-xNgp4g3Y.js → cloud-B-S4oOy9.js} +1 -1
  102. package/web-app/dist/assets/{code-xml-B-lyNDLJ.js → code-xml-D3Femn_F.js} +1 -1
  103. package/web-app/dist/assets/{database-DoW5lmWc.js → database-DBSeFgO3.js} +1 -1
  104. package/web-app/dist/assets/{dollar-sign-BcARA5sC.js → dollar-sign-sgDgNuGE.js} +1 -1
  105. package/web-app/dist/assets/{file-code-corner-CehcII0J.js → file-code-corner-C-MbdZEc.js} +1 -1
  106. package/web-app/dist/assets/{file-plus-DI9Zo5hP.js → file-plus-CCB6yqXv.js} +1 -1
  107. package/web-app/dist/assets/{globe-CiZWBk3H.js → globe-DlbrNAj1.js} +1 -1
  108. package/web-app/dist/assets/{hammer-3qv_T2N-.js → hammer-B-DWm695.js} +1 -1
  109. package/web-app/dist/assets/{index-gF9grAz-.js → index-Dvny-1SU.js} +3 -3
  110. package/web-app/dist/assets/{layers-CN7d0By9.js → layers-99qsKpOT.js} +1 -1
  111. package/web-app/dist/assets/{loader-circle-DywcM-im.js → loader-circle-ZvZyZl3J.js} +1 -1
  112. package/web-app/dist/assets/{lock-_hdSmkU4.js → lock-DosGjcxP.js} +1 -1
  113. package/web-app/dist/assets/{package-BV5zgqxJ.js → package-C-PumesN.js} +1 -1
  114. package/web-app/dist/assets/{plus-CU_Hh4-x.js → plus-CTYYskTB.js} +1 -1
  115. package/web-app/dist/assets/{refresh-cw-DMK-fNl-.js → refresh-cw-qW2ztTFS.js} +1 -1
  116. package/web-app/dist/assets/{rotate-ccw-CEB3_v5_.js → rotate-ccw-ZIKrf-vO.js} +1 -1
  117. package/web-app/dist/assets/{scroll-text-BSdXxcRN.js → scroll-text-BamlXjAT.js} +1 -1
  118. package/web-app/dist/assets/{server-CAMlP1yJ.js → server-BljXb79I.js} +1 -1
  119. package/web-app/dist/assets/{shield-alert-B-r1Dt3h.js → shield-alert-Dr6eT9c_.js} +1 -1
  120. package/web-app/dist/assets/{thumbs-up-CYfxw9o4.js → thumbs-up-v3oBXZgU.js} +1 -1
  121. package/web-app/dist/assets/{trash-2-DX3nGPS8.js → trash-2-BOuIrCpi.js} +1 -1
  122. package/web-app/dist/assets/{trending-down-Bt-hSntd.js → trending-down-Dp7jMCgH.js} +1 -1
  123. package/web-app/dist/assets/{trending-up-DVSlvQp8.js → trending-up-CDiur-nP.js} +1 -1
  124. package/web-app/dist/assets/{upload-D45RRHAP.js → upload-DxIHXhNE.js} +1 -1
  125. package/web-app/dist/assets/{usePolling-fq_Xj8iw.js → usePolling-D2Uu-YZb.js} +1 -1
  126. package/web-app/dist/assets/{user-BWcJkLlr.js → user-BVWCUAfF.js} +1 -1
  127. package/web-app/dist/index.html +1 -1
  128. package/packages/control-plane/src/server/actions.ts +0 -60
  129. package/packages/control-plane/ui/dist/assets/index-CUQ65vJY.css +0 -1
  130. package/packages/control-plane/ui/dist/assets/index-CwwGfp6E.js +0 -129
@@ -0,0 +1,319 @@
1
+ // CPE-14: GET and PUT /v1/config for the repo's loki.yaml. Both live on the loopback-only router.
2
+ // Writes keep comments (yaml Document API), are atomic (temp file plus rename in the same directory), use a sha256 ETag with If-Match,
3
+ // and refuse anything that looks like a secret: loki.yaml stores env var NAMES only (schemas/loki-yaml.schema.json).
4
+ import { createHash, randomBytes } from "node:crypto";
5
+ import { closeSync, fchmodSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
6
+ import { basename, dirname, join, sep } from "node:path";
7
+ import type { Context } from "hono";
8
+ import { isMap, isScalar, parseDocument, visit, type Document } from "yaml";
9
+ import { audit } from "../audit.ts";
10
+ import { originOk } from "./start.ts";
11
+ import type { RouteCtx } from "./index.ts";
12
+ import schema from "../../../../../schemas/loki-yaml.schema.json";
13
+
14
+ const MAX_BODY = 100_000;
15
+ const MAX_FILE = 1_000_000;
16
+
17
+ type Json = unknown;
18
+ type Schema = { type?: string; enum?: unknown[]; pattern?: string; minLength?: number; minimum?: number; maximum?: number; exclusiveMinimum?: number; properties?: Record<string, Schema>; additionalProperties?: boolean | Schema; items?: Schema };
19
+
20
+ const isObj = (v: Json): v is Record<string, Json> => typeof v === "object" && v !== null && !Array.isArray(v);
21
+
22
+ /** Validates the JSON-Schema subset schemas/loki-yaml.schema.json uses. Returns one message per violation (paths only, never values). */
23
+ export function validateConfig(value: Json, s: Schema = schema as Schema, path = ""): string[] {
24
+ const at = path || "(root)";
25
+ const errs: string[] = [];
26
+ const join2 = (k: string) => (path ? `${path}.${k}` : k);
27
+ if (s.type === "object") {
28
+ if (!isObj(value)) return [`${at}: must be an object`];
29
+ for (const [k, v] of Object.entries(value)) {
30
+ const sub = Object.hasOwn(s.properties ?? {}, k) ? s.properties![k] : undefined;
31
+ if (sub) errs.push(...validateConfig(v, sub, join2(k)));
32
+ else if (isObj(s.additionalProperties)) errs.push(...validateConfig(v, s.additionalProperties as Schema, join2(k)));
33
+ else if (s.additionalProperties === false) errs.push(`${join2(k)}: unknown key`);
34
+ }
35
+ } else if (s.type === "array") {
36
+ if (!Array.isArray(value)) return [`${at}: must be an array`];
37
+ if (s.items) value.forEach((v, i) => errs.push(...validateConfig(v, s.items!, `${path}[${i}]`)));
38
+ } else if (s.type === "string") {
39
+ if (typeof value !== "string") return [`${at}: must be a string`];
40
+ if (s.minLength !== undefined && value.length < s.minLength) errs.push(`${at}: must not be empty`);
41
+ if (s.pattern && !new RegExp(s.pattern).test(value)) errs.push(`${at}: wrong format`);
42
+ if (s.enum && !s.enum.includes(value)) errs.push(`${at}: must be one of ${s.enum.join(", ")}`);
43
+ } else if (s.type === "integer" || s.type === "number") {
44
+ if (typeof value !== "number" || !Number.isFinite(value) || (s.type === "integer" && !Number.isInteger(value))) return [`${at}: must be ${s.type === "integer" ? "an integer" : "a number"}`];
45
+ if (s.minimum !== undefined && value < s.minimum) errs.push(`${at}: must be at least ${s.minimum}`);
46
+ if (s.maximum !== undefined && value > s.maximum) errs.push(`${at}: must be at most ${s.maximum}`);
47
+ if (s.exclusiveMinimum !== undefined && value <= s.exclusiveMinimum) errs.push(`${at}: must be greater than ${s.exclusiveMinimum}`);
48
+ }
49
+ return errs;
50
+ }
51
+
52
+ const SECRET_PATTERNS: RegExp[] = [
53
+ /sk-[A-Za-z0-9_-]{8,}/, /gh[pousr]_[A-Za-z0-9]{8,}/, /github_pat_[A-Za-z0-9_]{8,}/, /xox[abprs]-[A-Za-z0-9-]{4,}/,
54
+ /AKIA[0-9A-Z]{12,}/, /AIza[0-9A-Za-z_-]{20,}/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bBearer\s+\S{8,}/i,
55
+ /hooks\.slack\.com\/services\//i, /discord(app)?\.com\/api\/webhooks\//i, /:\/\/[^\s/:@]+:[^\s/@]+@/,
56
+ ];
57
+
58
+ const entropy = (s: string): number => {
59
+ const n = new Map<string, number>();
60
+ for (const ch of s) n.set(ch, (n.get(ch) ?? 0) + 1);
61
+ let h = 0;
62
+ for (const c of n.values()) { const p = c / s.length; h -= p * Math.log2(p); }
63
+ return h;
64
+ };
65
+
66
+ /** True when a string looks like a credential: a known token shape or a long mixed high-entropy run. */
67
+ export function looksLikeSecret(s: string): boolean {
68
+ if (SECRET_PATTERNS.some((r) => r.test(s))) return true;
69
+ for (const tok of s.split(/[\s/\\:,;=]+/)) {
70
+ if (tok.length >= 24 && /^[A-Za-z0-9+_-]+$/.test(tok) && /[A-Za-z]/.test(tok) && /\d/.test(tok) && entropy(tok) >= 3.8) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /** Paths (never values) of every key or string that looks like a secret. */
76
+ export function findSecrets(v: Json, path = ""): string[] {
77
+ if (typeof v === "string") return looksLikeSecret(v) ? [path || "(root)"] : [];
78
+ if (Array.isArray(v)) return v.flatMap((x, i) => findSecrets(x, `${path}[${i}]`));
79
+ if (isObj(v)) {
80
+ return Object.entries(v).flatMap(([k, x]) => [
81
+ ...(looksLikeSecret(k) ? [`${path ? `${path}.` : ""}(key)`] : []),
82
+ ...findSecrets(x, path ? `${path}.${k}` : k),
83
+ ]);
84
+ }
85
+ return [];
86
+ }
87
+
88
+ const sha = (b: string | Buffer): string => createHash("sha256").update(b).digest("hex");
89
+ const canon = (v: Json): Json => (Array.isArray(v) ? v.map(canon) : isObj(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canon(v[k])])) : v);
90
+ const deepEq = (a: Json, b: Json): boolean => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
91
+
92
+ /** Applies `next` onto the document key by key, so comments on untouched nodes survive. */
93
+ function sync(doc: Document, path: string[], prev: Json, next: Json): void {
94
+ if (isObj(next) && isObj(prev)) {
95
+ for (const k of Object.keys(prev)) if (!Object.hasOwn(next, k)) doc.deleteIn([...path, k]);
96
+ for (const [k, v] of Object.entries(next)) sync(doc, [...path, k], prev[k], v);
97
+ return;
98
+ }
99
+ if (prev !== undefined && deepEq(prev, next)) return;
100
+ doc.setIn(path, doc.createNode(next));
101
+ }
102
+
103
+ const MAX_DEPTH = 24;
104
+ /** True when nesting exceeds `max`. Iterative, so hostile input cannot overflow the stack. */
105
+ export function tooDeep(v: Json, max = MAX_DEPTH): boolean {
106
+ const stack: Array<[Json, number]> = [[v, 1]];
107
+ while (stack.length) {
108
+ const [x, d] = stack.pop()!;
109
+ if (typeof x !== "object" || x === null) continue;
110
+ if (d > max) return true;
111
+ for (const y of Array.isArray(x) ? x : Object.values(x)) stack.push([y, d + 1]);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ const hasShell = (w: Json): boolean =>
117
+ isObj(w) && ((isObj(w.integration) && "command" in w.integration) || (Array.isArray(w.repos) && w.repos.some((r) => isObj(r) && "setup" in r)));
118
+
119
+ /** A workspace holding any shell string (repo setup or integration.command) is read-only as a whole: repos, paths and names decide where it runs. */
120
+ export function shellLocked(prev: Json, next: Json): boolean {
121
+ const pw = isObj(prev) && isObj(prev.workspaces) ? prev.workspaces : {};
122
+ const nw = isObj(next) && isObj(next.workspaces) ? next.workspaces : {};
123
+ for (const name of new Set([...Object.keys(pw), ...Object.keys(nw)])) {
124
+ const a = Object.hasOwn(pw, name) ? pw[name] : undefined;
125
+ const b = Object.hasOwn(nw, name) ? nw[name] : undefined;
126
+ if ((hasShell(a) || hasShell(b)) && !deepEq(a, b)) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /** Paths of workspace repo entries whose `path` has a `..` segment and was not already that exact value in `prev` (existing files keep their own). */
132
+ export function traversalPaths(cfg: Json, prev: Json = {}): string[] {
133
+ const pw = isObj(prev) && isObj(prev.workspaces) ? prev.workspaces : {};
134
+ const out: string[] = [];
135
+ const ws = isObj(cfg) && isObj(cfg.workspaces) ? cfg.workspaces : {};
136
+ for (const [name, w] of Object.entries(ws)) {
137
+ if (!isObj(w) || !Array.isArray(w.repos)) continue;
138
+ w.repos.forEach((r, i) => { if (isObj(r) && typeof r.path === "string" && r.path.split(/[\\/]+/).includes("..")) {
139
+ const old = Object.hasOwn(pw, name) && isObj(pw[name]) && Array.isArray((pw[name] as Record<string, Json>).repos) ? ((pw[name] as Record<string, Json[]>).repos![i] as Json) : undefined;
140
+ if (!(isObj(old) && old.path === r.path)) out.push(`workspaces.${name}.repos[${i}].path`);
141
+ } });
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /** Paths of every mapping key named `<<` (a YAML 1.1 merge key; PyYAML would merge it). Iterative. */
147
+ export function mergeKeyPaths(v: Json): string[] {
148
+ const out: string[] = [];
149
+ const stack: Array<[Json, string]> = [[v, ""]];
150
+ while (stack.length) {
151
+ const [x, p] = stack.pop()!;
152
+ if (Array.isArray(x)) x.forEach((y, i) => stack.push([y, `${p}[${i}]`]));
153
+ else if (isObj(x)) for (const [k, y] of Object.entries(x)) { if (k === "<<") out.push(p || "(root)"); stack.push([y, p ? `${p}.${k}` : k]); }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ const V11 = { version: "1.1", schema: "yaml-1.1", merge: true } as const;
159
+ const readsAsString11 = (s: string): boolean => {
160
+ try { const d = parseDocument(s, V11); return !d.errors.length && d.toJS() === s; } catch { return false; }
161
+ };
162
+
163
+ /** Conservative plain-scalar allowlist: anything outside it is double-quoted so no YAML reader has to guess. */
164
+ const PLAIN_OK = /^[A-Za-z0-9_./@+-][A-Za-z0-9_./@+ -]*$/;
165
+ const NUMERIC_LIKE = /^[-+.\d][\d._+-]*([eE][-+]?\d+)?$/;
166
+ /** True when a string must be written double-quoted (not on the allowlist, `-`, number-like, or something YAML 1.1 reads as a non-string). */
167
+ export function needsQuote(s: string): boolean {
168
+ return !PLAIN_OK.test(s) || s.endsWith(" ") || s === "-" || NUMERIC_LIKE.test(s) || !readsAsString11(s);
169
+ }
170
+
171
+ /** Anything outside PyYAML's reader-accepted set; output containing one is never written. */
172
+ const PYYAML_REJECTS = /[^\x09\x0A\x0D\x20-\x7E\x85\xA0-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/u;
173
+
174
+ /** Renders the document with every string that needs it double-quoted (U+0085, U+2028, U+2029 escaped, since PyYAML mishandles them raw), then re-parses under 1.2 and 1.1; null unless both equal `next`. `quote` is injectable so tests can prove the re-parse refuses on its own. */
175
+ export function renderVerified(doc: Document, next: Json, quote: (s: string) => boolean = needsQuote): string | null {
176
+ try {
177
+ visit(doc, { Scalar(_k, n) { if (typeof n.value === "string" && quote(n.value)) n.type = "QUOTE_DOUBLE"; } });
178
+ // U+2028/U+2029 are escaped for comments' sake too: PyYAML reads them raw inside scalars. The final class replace is safe globally because PLAIN_OK forces any such string into double quotes.
179
+ const out = String(doc).replace(/\u0085/g, "\\N").replace(/\u2028/g, "\\L").replace(/\u2029/g, "\\P")
180
+ .replace(/[\x7f-\x84\x86-\x9f]/g, (c) => "\\x" + c.charCodeAt(0).toString(16))
181
+ .replace(/\ufffe/g, "\\uFFFE").replace(/\uffff/g, "\\uFFFF");
182
+ if (PYYAML_REJECTS.test(out)) return null;
183
+ const back = parseDocument(out);
184
+ if (back.errors.length || !deepEq(back.toJS(), next)) return null;
185
+ const back11 = parseDocument(out, V11);
186
+ return back11.errors.length || !deepEq(back11.toJS(), next) ? null : out;
187
+ } catch { return null; }
188
+ }
189
+
190
+ type Resolved = { ok: true; file: string; exists: boolean } | { ok: false; error: string; status: 400 | 422 };
191
+
192
+ /** loki.yaml in the repo root. A symlink is followed only when it resolves to a regular file inside the repo. */
193
+ export function resolveConfigFile(repoDir: string): Resolved {
194
+ let root: string;
195
+ try { root = realpathSync(repoDir); } catch { return { ok: false, status: 400, error: "repo directory not found" }; }
196
+ const p = join(root, "loki.yaml");
197
+ let st;
198
+ try { st = lstatSync(p); } catch { return { ok: true, file: p, exists: false }; }
199
+ if (st.isSymbolicLink()) {
200
+ let real: string;
201
+ try { real = realpathSync(p); } catch { return { ok: false, status: 400, error: "loki.yaml is a broken symlink" }; }
202
+ if (!real.startsWith(root + sep)) return { ok: false, status: 400, error: "loki.yaml is a symlink that resolves outside the repo" };
203
+ if (!statSync(real).isFile()) return { ok: false, status: 422, error: "loki.yaml is not a regular file" };
204
+ return { ok: true, file: real, exists: true };
205
+ }
206
+ if (!st.isFile()) return { ok: false, status: 422, error: "loki.yaml is not a regular file" };
207
+ return { ok: true, file: p, exists: true };
208
+ }
209
+
210
+ function atomicWrite(file: string, text: string, mode: number): void {
211
+ const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
212
+ const fd = openSync(tmp, "wx", mode);
213
+ try {
214
+ writeSync(fd, text);
215
+ fchmodSync(fd, mode);
216
+ fsyncSync(fd);
217
+ } catch (e) { closeSync(fd); try { unlinkSync(tmp); } catch { /* best effort */ } throw e; }
218
+ closeSync(fd);
219
+ try { renameSync(tmp, file); } catch (e) { try { unlinkSync(tmp); } catch { /* best effort */ } throw e; }
220
+ }
221
+
222
+ const etagOf = (raw: string): string => `"${sha(raw)}"`;
223
+ const norm = (h: string | undefined): string | null => (h ? h.trim().replace(/^W\//, "").replace(/^"|"$/g, "") : null);
224
+
225
+ export function mount(ctx: RouteCtx): void {
226
+ const { act, db, repoDir, local } = ctx;
227
+
228
+ const deny = (c: Context, kind: string, status: 400 | 403 | 409 | 413 | 422 | 428 | 500, error: string, extra: Record<string, unknown> = {}) => {
229
+ audit(db, { kind, target: "loki.yaml", result: status === 409 ? "conflict" : status === 500 ? "error" : "refused", detail: error });
230
+ return c.json({ error, ...extra }, status);
231
+ };
232
+
233
+ act.get("/v1/config", (c: Context) => {
234
+ if (!ctx.peerIsLoopback(c)) return deny(c, "config.read", 403, "loopback requests only");
235
+ try {
236
+ const r = resolveConfigFile(repoDir);
237
+ if (!r.ok) return deny(c, "config.read", r.status, r.error);
238
+ let raw = "";
239
+ if (r.exists) {
240
+ if (statSync(r.file).size > MAX_FILE) return deny(c, "config.read", 413, "loki.yaml is too large");
241
+ raw = readFileSync(r.file, "utf8");
242
+ }
243
+ const doc = parseDocument(raw);
244
+ let config: Json = {};
245
+ let errors: string[] = doc.errors.map((e) => e.message.split("\n")[0]!);
246
+ if (!errors.length) {
247
+ config = doc.toJS({ maxAliasCount: 20 }) ?? {};
248
+ if (tooDeep(config)) { config = {}; errors = ["(root): nested too deeply"]; } else errors = validateConfig(config);
249
+ }
250
+ c.header("ETag", etagOf(raw));
251
+ return c.json({ path: "loki.yaml", exists: r.exists, etag: etagOf(raw), config, errors });
252
+ } catch (e) {
253
+ return deny(c, "config.read", 422, `loki.yaml could not be read safely: ${(e as Error).message.split("\n")[0]}`);
254
+ }
255
+ });
256
+
257
+ act.put("/v1/config", async (c: Context) => {
258
+ const K = "config.update";
259
+ if (!local(c)) return deny(c, K, 403, "loopback JSON requests only");
260
+ if (!originOk(c.req.header("origin"))) return deny(c, K, 403, "origin not allowed");
261
+ try {
262
+ const text = await c.req.text();
263
+ if (text.length > MAX_BODY) return deny(c, K, 413, "body too large");
264
+ const refuse = (status: 400 | 409 | 422 | 428, error: string, extra: Record<string, unknown> = {}) => deny(c, K, status, error, extra);
265
+ const ifMatch = norm(c.req.header("if-match"));
266
+ if (!ifMatch) return refuse(428, "If-Match is required");
267
+ let body: unknown;
268
+ try { body = JSON.parse(text); } catch { return refuse(400, "invalid JSON"); }
269
+ if (tooDeep(body)) return refuse(422, "config is nested too deeply");
270
+ const next = isObj(body) ? body.config : undefined;
271
+ if (!isObj(next)) return refuse(400, "body must be {config: object}");
272
+
273
+ const merge = mergeKeyPaths(next);
274
+ if (merge.length) return refuse(422, "the YAML merge key << is not allowed", { paths: merge });
275
+ const badNames = Object.keys(isObj(next.workspaces) ? next.workspaces : {}).filter((n) => !/^[A-Za-z0-9_.-]+$/.test(n) || n === "." || n === "..");
276
+ if (badNames.length) return refuse(422, "workspace names may only use letters, digits, _ . and -", { paths: badNames.map((_n, i) => `workspaces.(name ${i + 1})`) });
277
+ const secrets = findSecrets(next);
278
+ if (secrets.length) return refuse(422, "value looks like a secret; loki.yaml stores env var names only", { paths: secrets });
279
+ const errors = validateConfig(next);
280
+ if (errors.length) return refuse(422, "config does not match schemas/loki-yaml.schema.json", { errors });
281
+
282
+ const r = resolveConfigFile(repoDir);
283
+ if (!r.ok) return refuse(r.status, r.error);
284
+ let raw = "";
285
+ if (r.exists) {
286
+ if (statSync(r.file).size > MAX_FILE) return refuse(400, "loki.yaml is too large");
287
+ raw = readFileSync(r.file, "utf8");
288
+ }
289
+ if (!(ifMatch === "*" ? r.exists : sha(raw) === ifMatch)) return refuse(409, "loki.yaml changed since it was read", { etag: etagOf(raw) });
290
+
291
+ const doc = parseDocument(raw);
292
+ if (doc.errors.length) return refuse(422, "existing loki.yaml does not parse; fix it by hand first");
293
+ if (doc.contents !== null && !isMap(doc.contents)) return refuse(422, "existing loki.yaml root is not a mapping");
294
+ const prev = (doc.toJS({ maxAliasCount: 20 }) ?? {}) as Record<string, Json>;
295
+ if (tooDeep(prev)) return refuse(422, "existing loki.yaml is nested too deeply");
296
+ const trav = traversalPaths(next, prev);
297
+ if (trav.length) return refuse(422, "repo paths must not contain .. segments", { paths: trav });
298
+ if (shellLocked(prev, next)) return refuse(422, "edit shell commands in loki.yaml directly");
299
+ if (doc.contents === null) doc.contents = doc.createNode({}) as typeof doc.contents;
300
+ sync(doc, [], prev, next);
301
+ const out = renderVerified(doc, next);
302
+ if (out === null) return refuse(400, "could not render the config safely");
303
+
304
+ const changed = [...new Set([...Object.keys(prev), ...Object.keys(next)])].filter((k) => !deepEq(prev[k], next[k]));
305
+ try {
306
+ const mode = r.exists ? statSync(r.file).mode & 0o777 : 0o644;
307
+ if (r.exists) atomicWrite(`${r.file}.bak`, raw, 0o600);
308
+ atomicWrite(r.file, out, mode);
309
+ } catch {
310
+ return deny(c, K, 500, "could not write loki.yaml");
311
+ }
312
+ audit(db, { kind: K, target: "loki.yaml", result: "updated", detail: `sections: ${changed.join(", ") || "none"}` });
313
+ c.header("ETag", etagOf(out));
314
+ return c.json({ ok: true, etag: etagOf(out), config: next });
315
+ } catch (e) {
316
+ return deny(c, K, 422, `config could not be processed: ${(e as Error).message.split("\n")[0]}`);
317
+ }
318
+ });
319
+ }
@@ -0,0 +1,75 @@
1
+ // CPE-09 run control: POST /v1/runs/:source/:run/stop | retry | resume. Loopback-only (registered on `act`, real peer checked by ctx.local), every action audited.
2
+ // stop signals only the pid recorded in <run dir>/run.pid after three checks (see verifyRunPid); it never kills by name or pattern.
3
+ // retry and resume spawn `loki` through the same argv-only, stripped-env path as /v1/start (spawnStart).
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { join, resolve, sep } from "node:path";
6
+ import { and, eq } from "drizzle-orm";
7
+ import { verifyRunPid } from "../../../../../loki-ts/src/util/run_pid.ts";
8
+ import { localRepos, runs } from "../../db/schema.ts";
9
+ import { planStart, spawnStart } from "../spawn.ts";
10
+ import { blockedQuestion, defaultAnswerDir } from "../answer.ts";
11
+ import { audit } from "../audit.ts";
12
+ import { loadEvents } from "../runs.ts";
13
+ import type { RouteCtx } from "./index.ts";
14
+
15
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
16
+
17
+ const busy = new Set<string>();
18
+
19
+ export function mount(ctx: RouteCtx): void {
20
+ const { act, db } = ctx;
21
+ const bin = ctx.startBin ?? "loki", spawnIt = ctx.spawnImpl ?? spawnStart, answerDir = ctx.answerDir ?? defaultAnswerDir();
22
+ type Done = (status: 200 | 400 | 404 | 409 | 500 | 501, result: string, body: Record<string, unknown>, detail?: string) => Response;
23
+
24
+ const guarded = (kind: "stop" | "retry" | "resume", handler: (a: { source: string; run: string; repo: string; runDir: string }, done: Done) => Promise<Response> | Response) =>
25
+ act.post(`/v1/runs/:source/:run/${kind}`, async (c) => {
26
+ if (!ctx.local(c)) return c.json({ error: "loopback JSON requests only" }, 403);
27
+ const source = c.req.param("source"), id = c.req.param("run"), target = `${source}/${id}`;
28
+ const done: Done = (status, result, body, detail) => { audit(db, { kind: `run.${kind}`, target, result, detail }); return c.json(body, status); };
29
+ if (!ID.test(source) || !ID.test(id) || source.includes("..") || id.includes("..")) return done(400, "refused", { error: "invalid run id" }, "invalid id");
30
+ const repoRow = db.select().from(localRepos).where(eq(localRepos.sourceId, source)).get();
31
+ let repo = "";
32
+ try { repo = repoRow ? realpathSync(repoRow.realpath) : ""; } catch { repo = ""; }
33
+ if (!repo) return done(404, "refused", { error: "this run's repo is not known on this machine" });
34
+ const runsRoot = join(repo, ".loki", "runs"), runDir = resolve(runsRoot, id);
35
+ if (!runDir.startsWith(runsRoot + sep)) return done(400, "refused", { error: "invalid run id" }, "path escape");
36
+ return handler({ source, run: id, repo, runDir }, done);
37
+ });
38
+
39
+ guarded("stop", (a, done) => {
40
+ const v = verifyRunPid(a.runDir, a.run);
41
+ if (!v.ok) return done(409, "refused", { error: v.reason }, v.reason);
42
+ try { process.kill(v.pid, "SIGTERM"); } catch (e) { return done(500, "error", { error: `could not signal the run: ${(e as Error).message}` }); }
43
+ return done(200, "ok", { ok: true, pid: v.pid }, `SIGTERM pid ${v.pid}`);
44
+ });
45
+
46
+ const launch = async (a: { repo: string }, argv: string[], done: Done, env: Record<string, string> = {}): Promise<Response> => {
47
+ if (busy.has(a.repo)) return done(409, "refused", { error: "a run is already starting or running in this repo" });
48
+ busy.add(a.repo);
49
+ const r = await spawnIt(argv, a.repo, () => busy.delete(a.repo), env);
50
+ if ("error" in r) { busy.delete(a.repo); return done(500, "error", { error: r.error }); }
51
+ return done(200, "ok", { ok: true, pid: r.pid, command: argv.slice(1).join(" ") }, argv.slice(1).join(" "));
52
+ };
53
+
54
+ // loki has no `retry` command: retry is a fresh `loki start` of the same issue ref (or the recorded task text), never the original argv.
55
+ guarded("retry", (a, done) => {
56
+ if (verifyRunPid(a.runDir, a.run).ok) return done(409, "refused", { error: "this run is still running; stop it first" });
57
+ const row = db.select().from(runs).where(and(eq(runs.sourceId, a.source), eq(runs.runId, a.run))).get();
58
+ if (!row) return done(404, "refused", { error: "run not found" });
59
+ const task = loadEvents(db, a.source, a.run).find((e) => e.type === "stage.completed" && e.stage === "intake")?.data.task;
60
+ const target = row.issueRef || (typeof task === "string" ? task : "");
61
+ const plan = target ? planStart({ target, repo: a.repo }, [a.repo], bin) : null;
62
+ if (!plan || !plan.ok) return done(501, "unsupported", { error: `retry is not supported for this run: ${plan ? plan.error : "no issue ref or task text was recorded"}` });
63
+ return launch(a, plan.argv, done, plan.env);
64
+ });
65
+
66
+ // resume = `loki answer <run>`: engine10 has no in-place resume, the answer rides into a fresh run (loki-ts/src/features/blocked_answer.ts).
67
+ guarded("resume", (a, done) => {
68
+ if (verifyRunPid(a.runDir, a.run).ok) return done(409, "refused", { error: "this run is still running" });
69
+ const row = db.select().from(runs).where(and(eq(runs.sourceId, a.source), eq(runs.runId, a.run))).get();
70
+ if (!row) return done(404, "refused", { error: "run not found" });
71
+ if (!blockedQuestion(loadEvents(db, a.source, a.run), row.verdict)) return done(409, "refused", { error: "run is not BLOCKED, so there is nothing to resume" });
72
+ if (!existsSync(join(answerDir, a.source, `${a.run}.answer.txt`))) return done(409, "refused", { error: "no answer recorded for this run; answer it first" });
73
+ return launch(a, [bin, "answer", a.run], done);
74
+ });
75
+ }
@@ -0,0 +1,70 @@
1
+ // CPE-13: GET /v1/stats/cost?group=day,model,repo,provider&since=. Computed from the runs table only.
2
+ // Measured = every session priced (cost_usd set). Partial = some sessions priced, run total unknown (partial_usd counts only the priced ones).
3
+ // Unmeasured = no priced session (subscription or local provider): never summed as 0.
4
+ import { gte } from "drizzle-orm";
5
+ import { runs } from "../../db/schema.ts";
6
+ import type { RouteCtx } from "./index.ts";
7
+
8
+ export const COST_DIMS = ["day", "model", "repo", "provider"] as const;
9
+ type CostDim = (typeof COST_DIMS)[number];
10
+
11
+ /** D82: API-key runs default to a $100 per-run cap; subscription runs have no dollar cap. loki.yaml budgets.per_run and --max-cost override. */
12
+ export const DEFAULT_API_KEY_CAP_USD = 100;
13
+
14
+ const NONE = "unknown";
15
+ type Run = typeof runs.$inferSelect;
16
+ type Acc = { key: Record<string, string>; runs: number; measured_runs: number; partial_runs: number; unmeasured_runs: number; measured_usd: number; partial_usd: number; input_tokens: number; output_tokens: number };
17
+ const mk = (key: Record<string, string>): Acc => ({ key, runs: 0, measured_runs: 0, partial_runs: 0, unmeasured_runs: 0, measured_usd: 0, partial_usd: 0, input_tokens: 0, output_tokens: 0 });
18
+ const add = (a: Acc, r: Run) => {
19
+ a.runs++;
20
+ if (r.costUsd !== null) { a.measured_runs++; a.measured_usd += r.costUsd; }
21
+ else if (r.measuredSessions > 0) { a.partial_runs++; a.partial_usd += r.partialUsd; }
22
+ else a.unmeasured_runs++;
23
+ a.input_tokens += r.inputTokens;
24
+ a.output_tokens += r.outputTokens;
25
+ };
26
+ const dimVal = (d: CostDim, r: Run): string =>
27
+ d === "day" ? (r.startedAt ? r.startedAt.slice(0, 10) : NONE) : d === "model" ? (r.model ?? NONE) : d === "provider" ? (r.provider ?? NONE) : (r.originRepo ?? NONE);
28
+ const round = (n: number) => Math.round(n * 1e6) / 1e6;
29
+ const out = (a: Acc) => ({ ...a.key, runs: a.runs, measured_runs: a.measured_runs, partial_runs: a.partial_runs, unmeasured_runs: a.unmeasured_runs, measured_usd: round(a.measured_usd), partial_usd: round(a.partial_usd), input_tokens: a.input_tokens, output_tokens: a.output_tokens });
30
+
31
+ export function mount(ctx: RouteCtx): void {
32
+ ctx.app.get("/v1/stats/cost", (c) => {
33
+ const dims = (c.req.query("group") ?? "day").split(",").map((s) => s.trim()).filter(Boolean) as CostDim[];
34
+ if (dims.length === 0 || dims.some((d) => !COST_DIMS.includes(d))) {
35
+ return c.json({ error: `group must be a comma list of ${COST_DIMS.join(", ")}` }, 400);
36
+ }
37
+ const since = c.req.query("since");
38
+ if (since !== undefined && Number.isNaN(Date.parse(since))) return c.json({ error: "since must be an ISO date" }, 400);
39
+ const q = ctx.db.select().from(runs);
40
+ const all = since ? q.where(gte(runs.startedAt, since)).all() : q.all();
41
+
42
+ const groups = new Map<string, Acc>();
43
+ const total = mk({});
44
+ let priced = 0, unpriced = 0;
45
+ for (const r of all) {
46
+ const key: Record<string, string> = {};
47
+ for (const d of dims) key[d] = dimVal(d, r);
48
+ const id = dims.map((d) => key[d]).join("\u0000");
49
+ let g = groups.get(id);
50
+ if (!g) { g = mk(key); groups.set(id, g); }
51
+ add(g, r);
52
+ add(total, r);
53
+ if (r.measuredSessions > 0) priced++; else unpriced++;
54
+ }
55
+ const rows = [...groups.values()].map(out).sort((a, b) => {
56
+ for (const d of dims) {
57
+ const x = (a as Record<string, unknown>)[d] as string, y = (b as Record<string, unknown>)[d] as string;
58
+ if (x !== y) return d === "day" ? y.localeCompare(x) : x.localeCompare(y);
59
+ }
60
+ return 0;
61
+ });
62
+ const { runs: n, ...rest } = out(total);
63
+ return c.json({
64
+ group: dims, since: since ?? null, rows,
65
+ totals: { runs: n, ...rest },
66
+ // A run with at least one priced session ran on an API key; one with none ran on a subscription or local provider.
67
+ budget: { api_key_default_cap_usd: DEFAULT_API_KEY_CAP_USD, subscription_cap: null, api_key_runs: priced, subscription_runs: unpriced },
68
+ });
69
+ });
70
+ }