@krapht/ork 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/dist/__build_info__-CtSVVuZE.mjs +691 -0
- package/dist/bin.mjs +1654 -3532
- package/dist/clean-Dcr807be.mjs +88 -0
- package/dist/context-C_bksb0C.mjs +351 -0
- package/dist/find-DptmbhGd.mjs +47 -0
- package/dist/format-J-a21Yvn.mjs +393 -0
- package/dist/in-a-run-D1Qc5Mit.mjs +22 -0
- package/dist/limits-BDZCmGEu.mjs +44 -0
- package/dist/list-ZvY2OcLq.mjs +110 -0
- package/dist/mcp-CHVKKg3B.mjs +91 -0
- package/dist/output-CXSJO00X.mjs +900 -0
- package/dist/pause-BPSmg4yz.mjs +65 -0
- package/dist/relay-Do-aNbSD.mjs +122 -0
- package/dist/remove-AwaMMaHf.mjs +194 -0
- package/dist/report-DK4LGqwF.mjs +129 -0
- package/dist/send-DhGBkowy.mjs +163 -0
- package/dist/{src-DAsOYtoR.mjs → src-BHnxpJyH.mjs} +496 -193
- package/dist/src-CGqMati3.mjs +12505 -0
- package/dist/src-CpDKIulI.mjs +3118 -0
- package/dist/src-UWkk1sl9.mjs +1899 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
- package/dist/ui/assets/app-BgVDC_5l.js +67 -0
- package/dist/ui/assets/app-DVf68fj5.css +1 -0
- package/dist/ui/assets/geist-mono-cyrillic-400-normal-C51Di1Mf.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-400-normal-CPJFfJgk.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-500-normal-D2cXvDHF.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-500-normal-DfuvdVgn.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-CkxHOdCb.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-XB2BPmDK.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-BY1KTvO5.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-C7aYOB1D.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-400-normal-B2yvC1Cq.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-500-normal-D-GG86Jb.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-400-normal-CfzLURNc.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-400-normal-DI-rJ0UV.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-500-normal-Buglb9-a.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-500-normal-CrWEgvU_.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-400-normal-BS9BGVxz.woff2 +0 -0
- package/dist/ui/assets/geist-mono-symbols2-400-normal-DjbiTEEA.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-500-normal-CmnCPGa0.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-500-normal-Q_ksOahU.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-400-normal-CKEaXIlx.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-400-normal-CZEPLOgu.woff +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-500-normal-Bs29RrUZ.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-500-normal-Dx9-epDo.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
- package/dist/ui/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
- package/dist/ui/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
- package/dist/ui/assets/index-BiO54L90.css +1 -0
- package/dist/ui/assets/index-MQNNgsyT.js +10 -0
- package/dist/ui/assets/theme-3xH5jxKs.js +1 -0
- package/dist/ui/favicon.ico +0 -0
- package/dist/ui/favicon.svg +4 -0
- package/dist/ui/index.html +30 -0
- package/dist/ui/ork-wordmark-small-on-light.svg +4 -0
- package/dist/ui/ork-wordmark-small.svg +4 -0
- package/dist/ui-DhutUMgQ.mjs +472 -0
- package/dist/usage-CT7Z-yjr.mjs +419 -0
- package/dist/watch-0Qr2UfCQ.mjs +394 -0
- package/dist/worktrees-DHl80k96.mjs +212 -0
- package/package.json +6 -4
- package/dist/src-CJ_zF0Zj.mjs +0 -5433
|
@@ -0,0 +1,3118 @@
|
|
|
1
|
+
import { $ as resumable, An as parseWorkflow, B as unlinkNodes, Dn as newWorkflowText, Dt as nodeSays, En as applyEdits, Ft as runGh, G as tellLiveRun, H as requestContinue, In as expandEdge, It as sendPost, L as linkNodes, Nt as DISCUSSIONS, On as readDocument, Pn as EdgeInputSchema, Pt as planPost, Q as detachResume, Rn as NodeSchema, Tn as WorkflowEditSchema, U as requestPause, W as requestStop, _n as inside, _t as walkChecks, at as explainWorkflowText, d as runningRunIds, dn as placeFiles, dt as runStatus, f as runningScript, fn as placeFor, ft as runTree, g as triggersReport, gn as workflowDirOf, gt as checkWorkflowText, it as followEvents, kn as renderJsonSchema, l as planUpgrade, lt as liveStatus, mn as runDirOf, mt as processAlive, o as limitsReport, on as loadAgents, p as detectInstaller, qt as readCondition, r as capsHere, rt as detachRun, u as releaseNotes, ut as pausedOf, vt as walkSummary, wn as skillsIn, yn as LESSON_CAP_TOKENS } from "./src-CGqMati3.mjs";
|
|
2
|
+
import { D as placeOf$1, E as lessonPlaceOf, O as feedbackActions, S as previewPost, T as acceptItem, _ as answerDecision, a as registryShown, c as checkDue, f as readUpdateCache, g as nodeSession, h as writeUpdateCache, i as registryOf, k as openFixRun, l as fetchLatest, m as updateCachePath, o as updateSwitchOff, p as refreshUpdateCache, s as runInstall, u as newerVersion, v as pendingDecisions, w as recordNote, y as builtinProblem } from "./__build_info__-CtSVVuZE.mjs";
|
|
3
|
+
import { n as newRunId, t as findRun } from "./find-DptmbhGd.mjs";
|
|
4
|
+
import { c as openStore, l as storePath, s as nodeOf } from "./output-CXSJO00X.mjs";
|
|
5
|
+
import { n as usageReport } from "./report-DK4LGqwF.mjs";
|
|
6
|
+
import { a as planClean, i as keyOf, o as stepActs, r as cleanStep, s as stepPrunes, t as applyClean } from "./clean-Dcr807be.mjs";
|
|
7
|
+
import { a as readWorktrees, c as planLine, i as projectHead, l as removeRefusal, n as lostWork, o as labelOf, r as planRemove, s as mergeStateOf, t as applyRemove, u as stateParts } from "./remove-AwaMMaHf.mjs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
// ../core/src/store/activity.ts
|
|
15
|
+
const LONGEST_LINE = 120;
|
|
16
|
+
/**
|
|
17
|
+
* The fields of a tool's input that say what it's working on, best first. Only
|
|
18
|
+
* these are shown, so what else a tool was given stays in the logs.
|
|
19
|
+
*/
|
|
20
|
+
const TARGETS = [
|
|
21
|
+
"file_path",
|
|
22
|
+
"notebook_path",
|
|
23
|
+
"path",
|
|
24
|
+
"url",
|
|
25
|
+
"pattern",
|
|
26
|
+
"query",
|
|
27
|
+
"command"
|
|
28
|
+
];
|
|
29
|
+
const oneLine = (text) => {
|
|
30
|
+
const line = text.replaceAll(/\s+/gu, " ").trim();
|
|
31
|
+
return line.length > LONGEST_LINE ? `${line.slice(0, 119)}…` : line;
|
|
32
|
+
};
|
|
33
|
+
const toolLine = ({ name, input }) => {
|
|
34
|
+
const target = TARGETS.map((field) => input[field]).find((value) => typeof value === "string" && value.trim() !== "");
|
|
35
|
+
if (typeof target !== "string") return name;
|
|
36
|
+
return oneLine(target.startsWith(`${name} `) ? target : `${name} ${target}`);
|
|
37
|
+
};
|
|
38
|
+
/** What a finished node said, or why it failed. */
|
|
39
|
+
const finishedNow = (ok, error, summary) => {
|
|
40
|
+
if (!ok) return error === void 0 ? void 0 : oneLine(error);
|
|
41
|
+
return typeof summary === "string" && summary.trim() !== "" ? oneLine(summary) : void 0;
|
|
42
|
+
};
|
|
43
|
+
/** Each node's activity, by id; a node that never started has none. */
|
|
44
|
+
const runActivity = (stored) => {
|
|
45
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
46
|
+
const of = (id) => {
|
|
47
|
+
const found = nodes.get(id);
|
|
48
|
+
if (found !== void 0) return found;
|
|
49
|
+
const fresh = {};
|
|
50
|
+
nodes.set(id, fresh);
|
|
51
|
+
return fresh;
|
|
52
|
+
};
|
|
53
|
+
for (const { at, event } of stored) if (event.type === "node.started") {
|
|
54
|
+
const node = of(event.id);
|
|
55
|
+
node.startedAt ??= at;
|
|
56
|
+
delete node.finishedAt;
|
|
57
|
+
delete node.now;
|
|
58
|
+
} else if (event.type === "node.event") {
|
|
59
|
+
if (event.event.type === "tool.called") of(event.id).now = toolLine(event.event);
|
|
60
|
+
} else if (event.type === "decision.opened") of(event.node).now = oneLine(`Asked: ${event.question}`);
|
|
61
|
+
else if (event.type === "decision.answered") of(event.node).now = oneLine(`Answered: ${event.answer}`);
|
|
62
|
+
else if (event.type === "decision.withdrawn") delete of(event.node).now;
|
|
63
|
+
else if (event.type === "node.finished") {
|
|
64
|
+
const node = of(event.id);
|
|
65
|
+
node.finishedAt = at;
|
|
66
|
+
const now = finishedNow(event.ok, event.error, event.output["summary"]);
|
|
67
|
+
if (now === void 0) delete node.now;
|
|
68
|
+
else node.now = now;
|
|
69
|
+
}
|
|
70
|
+
return nodes;
|
|
71
|
+
};
|
|
72
|
+
/** The ownership tree, with what each node is doing and when it ran. */
|
|
73
|
+
const activeTree = (stored) => {
|
|
74
|
+
const activity = runActivity(stored);
|
|
75
|
+
const dress = (node) => {
|
|
76
|
+
Object.assign(node, activity.get(node.id));
|
|
77
|
+
for (const child of node.children) dress(child);
|
|
78
|
+
};
|
|
79
|
+
const roots = runTree(stored.map(({ event }) => event));
|
|
80
|
+
for (const root of roots) dress(root);
|
|
81
|
+
return roots;
|
|
82
|
+
};
|
|
83
|
+
// ../server/src/contract-builder.ts
|
|
84
|
+
/** A new workflow's name, as its file and its `name:` take it. */
|
|
85
|
+
const NewWorkflowSchema = z.strictObject({ name: z.string().min(1) });
|
|
86
|
+
/** The server checks each edit; this file imports only types of the schema. */
|
|
87
|
+
const WorkflowEditsSchema = z.strictObject({ edits: z.array(z.custom()).min(1) });
|
|
88
|
+
/** Earlier text, and the file it was based on, to ask before replacing it. */
|
|
89
|
+
const DraftTextSchema = z.strictObject({
|
|
90
|
+
text: z.string(),
|
|
91
|
+
baseHash: z.string().nullable()
|
|
92
|
+
});
|
|
93
|
+
const SaveWorkflowSchema = z.strictObject({
|
|
94
|
+
/** Write over a file that changed since this draft began. */
|
|
95
|
+
force: z.boolean().optional() });
|
|
96
|
+
const BUILDER_API = {
|
|
97
|
+
builderSchema: {
|
|
98
|
+
method: "GET",
|
|
99
|
+
path: "/api/workflow-schema"
|
|
100
|
+
},
|
|
101
|
+
builderCatalog: {
|
|
102
|
+
method: "GET",
|
|
103
|
+
path: "/api/projects/:id/catalog"
|
|
104
|
+
},
|
|
105
|
+
builderDrafts: {
|
|
106
|
+
method: "GET",
|
|
107
|
+
path: "/api/projects/:id/builder"
|
|
108
|
+
},
|
|
109
|
+
builderView: {
|
|
110
|
+
method: "GET",
|
|
111
|
+
path: "/api/projects/:id/builder/:name"
|
|
112
|
+
},
|
|
113
|
+
builderCreate: {
|
|
114
|
+
method: "POST",
|
|
115
|
+
path: "/api/projects/:id/builder",
|
|
116
|
+
body: NewWorkflowSchema
|
|
117
|
+
},
|
|
118
|
+
builderEdit: {
|
|
119
|
+
method: "POST",
|
|
120
|
+
path: "/api/projects/:id/builder/:name/edits",
|
|
121
|
+
body: WorkflowEditsSchema
|
|
122
|
+
},
|
|
123
|
+
builderRestore: {
|
|
124
|
+
method: "POST",
|
|
125
|
+
path: "/api/projects/:id/builder/:name/text",
|
|
126
|
+
body: DraftTextSchema
|
|
127
|
+
},
|
|
128
|
+
builderDiscard: {
|
|
129
|
+
method: "POST",
|
|
130
|
+
path: "/api/projects/:id/builder/:name/discard"
|
|
131
|
+
},
|
|
132
|
+
builderSave: {
|
|
133
|
+
method: "POST",
|
|
134
|
+
path: "/api/projects/:id/builder/:name/save",
|
|
135
|
+
body: SaveWorkflowSchema
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
// ../server/src/contract-context.ts
|
|
139
|
+
const CONTEXT_API = {
|
|
140
|
+
runContext: {
|
|
141
|
+
method: "GET",
|
|
142
|
+
path: "/api/runs/:id/nodes/:node/context"
|
|
143
|
+
},
|
|
144
|
+
builderContext: {
|
|
145
|
+
method: "GET",
|
|
146
|
+
path: "/api/projects/:id/builder/:name/nodes/:node/context"
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
// ../server/src/contract-feedback.ts
|
|
150
|
+
const NoteSchema = z.strictObject({
|
|
151
|
+
/** Its first line is the title. */
|
|
152
|
+
text: z.string().min(1),
|
|
153
|
+
/** A node that ran in the run; the run itself without one. */
|
|
154
|
+
node: z.string().min(1).optional(),
|
|
155
|
+
kind: z.string().min(1).optional(),
|
|
156
|
+
severity: z.string().min(1).optional(),
|
|
157
|
+
rating: z.string().min(1).optional()
|
|
158
|
+
});
|
|
159
|
+
/** Dismissing an item, or restoring a dismissed one as `new`. */
|
|
160
|
+
const FeedbackStatusSchema = z.strictObject({ status: z.enum(["dismissed", "new"]) });
|
|
161
|
+
const AcceptSchema = z.strictObject({
|
|
162
|
+
/** Also write it into its workflow's lessons file. */
|
|
163
|
+
lesson: z.boolean(),
|
|
164
|
+
/** The lesson's words; the item's title by default. */
|
|
165
|
+
text: z.string().min(1).optional(),
|
|
166
|
+
/** Which workflow's lessons, when it was seen in more than one. */
|
|
167
|
+
workflow: z.string().min(1).optional()
|
|
168
|
+
});
|
|
169
|
+
const ApplySchema = z.strictObject({ driver: z.enum(["sdk", "cli"]).optional() });
|
|
170
|
+
const PublishPreviewSchema = z.strictObject({
|
|
171
|
+
/** Also hide the repositories' and workflows' names. */
|
|
172
|
+
hideNames: z.boolean() });
|
|
173
|
+
const PublishSchema = z.strictObject({
|
|
174
|
+
hideNames: z.boolean(),
|
|
175
|
+
/** The preview's digest: what the person saw, so nothing else is sent. */
|
|
176
|
+
digest: z.string().min(1)
|
|
177
|
+
});
|
|
178
|
+
const FEEDBACK_API = {
|
|
179
|
+
note: {
|
|
180
|
+
method: "POST",
|
|
181
|
+
path: "/api/runs/:id/notes",
|
|
182
|
+
body: NoteSchema
|
|
183
|
+
},
|
|
184
|
+
feedbackItems: {
|
|
185
|
+
method: "GET",
|
|
186
|
+
path: "/api/feedback"
|
|
187
|
+
},
|
|
188
|
+
feedbackItem: {
|
|
189
|
+
method: "GET",
|
|
190
|
+
path: "/api/feedback/:id"
|
|
191
|
+
},
|
|
192
|
+
feedbackStatus: {
|
|
193
|
+
method: "POST",
|
|
194
|
+
path: "/api/feedback/:id/status",
|
|
195
|
+
body: FeedbackStatusSchema
|
|
196
|
+
},
|
|
197
|
+
accept: {
|
|
198
|
+
method: "POST",
|
|
199
|
+
path: "/api/feedback/:id/accept",
|
|
200
|
+
body: AcceptSchema
|
|
201
|
+
},
|
|
202
|
+
apply: {
|
|
203
|
+
method: "POST",
|
|
204
|
+
path: "/api/feedback/:id/apply",
|
|
205
|
+
body: ApplySchema
|
|
206
|
+
},
|
|
207
|
+
publishPreview: {
|
|
208
|
+
method: "POST",
|
|
209
|
+
path: "/api/feedback/:id/publish/preview",
|
|
210
|
+
body: PublishPreviewSchema
|
|
211
|
+
},
|
|
212
|
+
publish: {
|
|
213
|
+
method: "POST",
|
|
214
|
+
path: "/api/feedback/:id/publish",
|
|
215
|
+
body: PublishSchema
|
|
216
|
+
},
|
|
217
|
+
split: {
|
|
218
|
+
method: "POST",
|
|
219
|
+
path: "/api/feedback/:id/split"
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
// ../server/src/contract-links.ts
|
|
223
|
+
/**
|
|
224
|
+
* @file The routes that link two nodes of live runs (RFC-003): list, link and
|
|
225
|
+
* end a link. It imports only `zod`, so a browser bundle can use it.
|
|
226
|
+
*/
|
|
227
|
+
/** Two nodes of live runs, each `<run>/<node>` (RFC-003). */
|
|
228
|
+
const LinkSchema = z.strictObject({
|
|
229
|
+
a: z.string().min(3),
|
|
230
|
+
b: z.string().min(3),
|
|
231
|
+
maxMessages: z.number().int().positive().optional()
|
|
232
|
+
});
|
|
233
|
+
const UnlinkSchema = z.strictObject({
|
|
234
|
+
a: z.string().min(3),
|
|
235
|
+
b: z.string().min(3)
|
|
236
|
+
});
|
|
237
|
+
const LINKS_API = {
|
|
238
|
+
links: {
|
|
239
|
+
method: "GET",
|
|
240
|
+
path: "/api/links",
|
|
241
|
+
query: z.strictObject({ run: z.string().min(1).optional() })
|
|
242
|
+
},
|
|
243
|
+
link: {
|
|
244
|
+
method: "POST",
|
|
245
|
+
path: "/api/links",
|
|
246
|
+
body: LinkSchema
|
|
247
|
+
},
|
|
248
|
+
unlink: {
|
|
249
|
+
method: "POST",
|
|
250
|
+
path: "/api/links/end",
|
|
251
|
+
body: UnlinkSchema
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const UPDATE_API = {
|
|
255
|
+
version: {
|
|
256
|
+
method: "GET",
|
|
257
|
+
path: "/api/version"
|
|
258
|
+
},
|
|
259
|
+
upgradePlan: {
|
|
260
|
+
method: "POST",
|
|
261
|
+
path: "/api/upgrade/plan"
|
|
262
|
+
},
|
|
263
|
+
upgrade: {
|
|
264
|
+
method: "POST",
|
|
265
|
+
path: "/api/upgrade",
|
|
266
|
+
body: z.strictObject({
|
|
267
|
+
/** The plan the person read; the server runs its own argv, never a sent one. */
|
|
268
|
+
planId: z.string().min(1) })
|
|
269
|
+
},
|
|
270
|
+
restart: {
|
|
271
|
+
method: "POST",
|
|
272
|
+
path: "/api/restart"
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
// ../server/src/contract-worktrees.ts
|
|
276
|
+
const Root = z.string().min(1);
|
|
277
|
+
const WorktreesQuerySchema = z.strictObject({
|
|
278
|
+
/** A project's root; every project Ork knows without one. */
|
|
279
|
+
project: Root.optional() });
|
|
280
|
+
const RemovePlanSchema = z.strictObject({
|
|
281
|
+
project: Root,
|
|
282
|
+
/** The item's branch, or a detached worktree's path. */
|
|
283
|
+
key: z.string().min(1),
|
|
284
|
+
/** Also delete the branch (implied for a branch with no worktree). */
|
|
285
|
+
deleteBranch: z.boolean()
|
|
286
|
+
});
|
|
287
|
+
const RemoveSchema = RemovePlanSchema.extend({
|
|
288
|
+
/** The plan needs it: removing loses work the person was shown. */
|
|
289
|
+
force: z.boolean(),
|
|
290
|
+
/** The plan's digest: what the person saw, so nothing else goes. */
|
|
291
|
+
digest: z.string().min(1)
|
|
292
|
+
});
|
|
293
|
+
const CleanPlanSchema = z.strictObject({
|
|
294
|
+
project: Root.optional(),
|
|
295
|
+
/** Also delete the merged branches (`--branches`). */
|
|
296
|
+
branches: z.boolean()
|
|
297
|
+
});
|
|
298
|
+
const CleanSchema = CleanPlanSchema.extend({ digest: z.string().min(1) });
|
|
299
|
+
const WORKTREES_API = {
|
|
300
|
+
worktrees: {
|
|
301
|
+
method: "GET",
|
|
302
|
+
path: "/api/worktrees",
|
|
303
|
+
query: WorktreesQuerySchema
|
|
304
|
+
},
|
|
305
|
+
worktreeRemovePlan: {
|
|
306
|
+
method: "POST",
|
|
307
|
+
path: "/api/worktrees/remove/plan",
|
|
308
|
+
body: RemovePlanSchema
|
|
309
|
+
},
|
|
310
|
+
worktreeRemove: {
|
|
311
|
+
method: "POST",
|
|
312
|
+
path: "/api/worktrees/remove",
|
|
313
|
+
body: RemoveSchema
|
|
314
|
+
},
|
|
315
|
+
worktreesCleanPlan: {
|
|
316
|
+
method: "POST",
|
|
317
|
+
path: "/api/worktrees/clean/plan",
|
|
318
|
+
body: CleanPlanSchema
|
|
319
|
+
},
|
|
320
|
+
worktreesClean: {
|
|
321
|
+
method: "POST",
|
|
322
|
+
path: "/api/worktrees/clean",
|
|
323
|
+
body: CleanSchema
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
// ../server/src/contract.ts
|
|
327
|
+
const Count = z.coerce.number().int().positive();
|
|
328
|
+
const Seq = z.coerce.number().int().nonnegative();
|
|
329
|
+
const Driver = z.enum(["sdk", "cli"]);
|
|
330
|
+
const RunsQuerySchema = z.strictObject({
|
|
331
|
+
project: z.string().min(1).optional(),
|
|
332
|
+
limit: Count.optional()
|
|
333
|
+
});
|
|
334
|
+
const UsageQuerySchema = z.strictObject({
|
|
335
|
+
project: z.string().min(1).optional(),
|
|
336
|
+
workflow: z.string().min(1).optional(),
|
|
337
|
+
/** ISO times: `since` included, `until` not. */
|
|
338
|
+
since: z.iso.datetime().optional(),
|
|
339
|
+
until: z.iso.datetime().optional()
|
|
340
|
+
});
|
|
341
|
+
const EventsQuerySchema = z.strictObject({
|
|
342
|
+
after: Seq.optional(),
|
|
343
|
+
node: z.string().min(1).optional(),
|
|
344
|
+
/** Only the first this many. */
|
|
345
|
+
limit: Count.optional(),
|
|
346
|
+
/** Only the newest this many, still oldest first. */
|
|
347
|
+
last: Count.optional()
|
|
348
|
+
});
|
|
349
|
+
const DecisionsQuerySchema = z.strictObject({
|
|
350
|
+
run: z.string().min(1).optional(),
|
|
351
|
+
project: z.string().min(1).optional()
|
|
352
|
+
});
|
|
353
|
+
const AnswerSchema = z.strictObject({ choose: z.string().min(1) });
|
|
354
|
+
const RunFlags = {
|
|
355
|
+
inputs: z.record(z.string(), z.string()).optional(),
|
|
356
|
+
driver: Driver.optional(),
|
|
357
|
+
inPlace: z.boolean().optional(),
|
|
358
|
+
allowDirty: z.boolean().optional()
|
|
359
|
+
};
|
|
360
|
+
const StartRunSchema = z.strictObject({
|
|
361
|
+
/** A registered project's id or root. */
|
|
362
|
+
project: z.string().min(1),
|
|
363
|
+
/** A workflow's name in the project, or a path inside the project. */
|
|
364
|
+
workflow: z.string().min(1),
|
|
365
|
+
...RunFlags
|
|
366
|
+
});
|
|
367
|
+
const ResumeRunSchema = z.strictObject(RunFlags);
|
|
368
|
+
const MessageSchema = z.strictObject({
|
|
369
|
+
node: z.string().min(1),
|
|
370
|
+
body: z.string().min(1)
|
|
371
|
+
});
|
|
372
|
+
/** Pause, continue or stop: the whole run, or `node` alone (ADR-041). */
|
|
373
|
+
const ControlSchema = z.strictObject({ node: z.string().min(1).optional() });
|
|
374
|
+
/** Every JSON route; the two event streams are in {@link STREAMS}. */
|
|
375
|
+
const API = {
|
|
376
|
+
health: {
|
|
377
|
+
method: "GET",
|
|
378
|
+
path: "/api/health"
|
|
379
|
+
},
|
|
380
|
+
...BUILDER_API,
|
|
381
|
+
...CONTEXT_API,
|
|
382
|
+
...FEEDBACK_API,
|
|
383
|
+
...WORKTREES_API,
|
|
384
|
+
...UPDATE_API,
|
|
385
|
+
projects: {
|
|
386
|
+
method: "GET",
|
|
387
|
+
path: "/api/projects"
|
|
388
|
+
},
|
|
389
|
+
workflows: {
|
|
390
|
+
method: "GET",
|
|
391
|
+
path: "/api/projects/:id/workflows"
|
|
392
|
+
},
|
|
393
|
+
runs: {
|
|
394
|
+
method: "GET",
|
|
395
|
+
path: "/api/runs",
|
|
396
|
+
query: RunsQuerySchema
|
|
397
|
+
},
|
|
398
|
+
run: {
|
|
399
|
+
method: "GET",
|
|
400
|
+
path: "/api/runs/:id"
|
|
401
|
+
},
|
|
402
|
+
tree: {
|
|
403
|
+
method: "GET",
|
|
404
|
+
path: "/api/runs/:id/tree"
|
|
405
|
+
},
|
|
406
|
+
graph: {
|
|
407
|
+
method: "GET",
|
|
408
|
+
path: "/api/runs/:id/graph"
|
|
409
|
+
},
|
|
410
|
+
events: {
|
|
411
|
+
method: "GET",
|
|
412
|
+
path: "/api/runs/:id/events",
|
|
413
|
+
query: EventsQuerySchema
|
|
414
|
+
},
|
|
415
|
+
decisions: {
|
|
416
|
+
method: "GET",
|
|
417
|
+
path: "/api/decisions",
|
|
418
|
+
query: DecisionsQuerySchema
|
|
419
|
+
},
|
|
420
|
+
decision: {
|
|
421
|
+
method: "GET",
|
|
422
|
+
path: "/api/decisions/:id"
|
|
423
|
+
},
|
|
424
|
+
answer: {
|
|
425
|
+
method: "POST",
|
|
426
|
+
path: "/api/decisions/:id",
|
|
427
|
+
body: AnswerSchema
|
|
428
|
+
},
|
|
429
|
+
start: {
|
|
430
|
+
method: "POST",
|
|
431
|
+
path: "/api/runs",
|
|
432
|
+
body: StartRunSchema
|
|
433
|
+
},
|
|
434
|
+
resume: {
|
|
435
|
+
method: "POST",
|
|
436
|
+
path: "/api/runs/:id/resume",
|
|
437
|
+
body: ResumeRunSchema
|
|
438
|
+
},
|
|
439
|
+
stop: {
|
|
440
|
+
method: "POST",
|
|
441
|
+
path: "/api/runs/:id/stop",
|
|
442
|
+
body: ControlSchema
|
|
443
|
+
},
|
|
444
|
+
pause: {
|
|
445
|
+
method: "POST",
|
|
446
|
+
path: "/api/runs/:id/pause",
|
|
447
|
+
body: ControlSchema
|
|
448
|
+
},
|
|
449
|
+
continue: {
|
|
450
|
+
method: "POST",
|
|
451
|
+
path: "/api/runs/:id/continue",
|
|
452
|
+
body: ControlSchema
|
|
453
|
+
},
|
|
454
|
+
message: {
|
|
455
|
+
method: "POST",
|
|
456
|
+
path: "/api/runs/:id/messages",
|
|
457
|
+
body: MessageSchema
|
|
458
|
+
},
|
|
459
|
+
usage: {
|
|
460
|
+
method: "GET",
|
|
461
|
+
path: "/api/usage",
|
|
462
|
+
query: UsageQuerySchema
|
|
463
|
+
},
|
|
464
|
+
triggers: {
|
|
465
|
+
method: "GET",
|
|
466
|
+
path: "/api/triggers"
|
|
467
|
+
},
|
|
468
|
+
...LINKS_API
|
|
469
|
+
};
|
|
470
|
+
/** The Server-Sent Event streams, and the events each sends. */
|
|
471
|
+
const STREAMS = {
|
|
472
|
+
/**
|
|
473
|
+
* `run-event` per stored event (`id:` is its seq), then `end`. Starts after
|
|
474
|
+
* `Last-Event-ID`, or `?after=`.
|
|
475
|
+
*/
|
|
476
|
+
run: "/api/runs/:id/stream",
|
|
477
|
+
/**
|
|
478
|
+
* `runs`, `decisions`, `version`, each when it changes; `?project=` keeps
|
|
479
|
+
* one. `upgrade`: the upgrade's output, then `end` (contract-update.ts).
|
|
480
|
+
*/
|
|
481
|
+
overview: "/api/stream",
|
|
482
|
+
upgrade: "/api/upgrade/stream"
|
|
483
|
+
};
|
|
484
|
+
// ../server/src/graph.ts
|
|
485
|
+
/**
|
|
486
|
+
* @file A workflow or a run as the graph the UI draws: its nodes, its edges
|
|
487
|
+
* with their shorthand expanded, and its channels. Where they sit follows
|
|
488
|
+
* from those, so nothing about that is stored (RFC-008).
|
|
489
|
+
*/
|
|
490
|
+
const workflowGraph = (workflow) => ({
|
|
491
|
+
nodes: Object.entries(workflow.nodes).map(([id, node]) => ({
|
|
492
|
+
id,
|
|
493
|
+
type: node.type,
|
|
494
|
+
parent: node.parent
|
|
495
|
+
})),
|
|
496
|
+
edges: (workflow.edges ?? []).flatMap((input, index) => {
|
|
497
|
+
const expanded = expandEdge(input, index);
|
|
498
|
+
return expanded.ok ? expanded.edges.map(({ from, to, when, maxRounds }) => ({
|
|
499
|
+
from,
|
|
500
|
+
to,
|
|
501
|
+
when,
|
|
502
|
+
maxRounds
|
|
503
|
+
})) : [];
|
|
504
|
+
}),
|
|
505
|
+
channels: (workflow.messages ?? []).map(({ between: [a, b] }) => [a, b])
|
|
506
|
+
});
|
|
507
|
+
/** The graph of a workflow file, or `null` if it is gone or does not parse. */
|
|
508
|
+
const readWorkflowGraph = (file) => {
|
|
509
|
+
if (!existsSync(file)) return null;
|
|
510
|
+
const parsed = parseWorkflow(readFileSync(file, "utf8"));
|
|
511
|
+
if (!parsed.ok) return null;
|
|
512
|
+
return workflowGraph(parsed.workflow);
|
|
513
|
+
};
|
|
514
|
+
/**
|
|
515
|
+
* The graph of a run: what it recorded it ran, so editing the file later does
|
|
516
|
+
* not redraw it. A run from before edges were kept has only its file to go by.
|
|
517
|
+
*/
|
|
518
|
+
const runGraph = (events, file) => {
|
|
519
|
+
const started = events.findLast((event) => event.type === "run.started");
|
|
520
|
+
const onFile = readWorkflowGraph(file);
|
|
521
|
+
const nodes = started?.type === "run.started" ? started.nodes : [];
|
|
522
|
+
const edges = started?.type === "run.started" ? started.edges : void 0;
|
|
523
|
+
const channels = started?.type === "run.started" ? started.channels : void 0;
|
|
524
|
+
return {
|
|
525
|
+
nodes: nodes.length === 0 ? onFile?.nodes ?? [] : nodes.map(({ id, type, parent }) => ({
|
|
526
|
+
id,
|
|
527
|
+
type,
|
|
528
|
+
parent
|
|
529
|
+
})),
|
|
530
|
+
edges: edges ?? onFile?.edges ?? [],
|
|
531
|
+
channels: channels ?? onFile?.channels ?? [],
|
|
532
|
+
...started?.type === "run.started" && started.budgetUsd !== void 0 ? { budgetUsd: started.budgetUsd } : {}
|
|
533
|
+
};
|
|
534
|
+
};
|
|
535
|
+
// ../server/src/guards.ts
|
|
536
|
+
/**
|
|
537
|
+
* @file What the server lets in (ADR-039,
|
|
538
|
+
* specs/server/streams-contract-and-security.md#security): only its own
|
|
539
|
+
* loopback `Host` (against DNS rebinding); only with its token, from the
|
|
540
|
+
* session cookie or an `Authorization: Bearer` header; and, for a write sent
|
|
541
|
+
* with the cookie, only from its own `Origin` (against CSRF). The token in
|
|
542
|
+
* `?token=` is swapped once for the cookie, with a redirect that takes it out
|
|
543
|
+
* of the address bar. Pure: it reads the request's parts and decides.
|
|
544
|
+
*/
|
|
545
|
+
const WRITES = /* @__PURE__ */ new Set([
|
|
546
|
+
"POST",
|
|
547
|
+
"PUT",
|
|
548
|
+
"PATCH",
|
|
549
|
+
"DELETE"
|
|
550
|
+
]);
|
|
551
|
+
/** A new token: 32 random bytes. */
|
|
552
|
+
const newToken = () => randomBytes(32).toString("base64url");
|
|
553
|
+
/** The session cookie's name; one per port, as cookies ignore ports. */
|
|
554
|
+
const cookieName = (port) => `ork_ui_${String(port)}`;
|
|
555
|
+
/** Whether `given` is the token, compared in constant time. */
|
|
556
|
+
const sameToken = (given, token) => {
|
|
557
|
+
const a = Buffer.from(given);
|
|
558
|
+
const b = Buffer.from(token);
|
|
559
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
560
|
+
};
|
|
561
|
+
const header = (request, name) => {
|
|
562
|
+
const value = request.headers[name];
|
|
563
|
+
return Array.isArray(value) ? value[0] : value;
|
|
564
|
+
};
|
|
565
|
+
/** The server's own origins: `127.0.0.1` and `localhost` on its port. */
|
|
566
|
+
const ownOrigins = (port) => [`http://127.0.0.1:${String(port)}`, `http://localhost:${String(port)}`];
|
|
567
|
+
const cookieValue = (request, name) => (header(request, "cookie") ?? "").split(";").map((part) => part.trim().split("=")).find(([key]) => key === name)?.slice(1).join("=");
|
|
568
|
+
const bearer = (request) => {
|
|
569
|
+
return /^Bearer\s+(\S+)$/iu.exec(header(request, "authorization") ?? "")?.[1];
|
|
570
|
+
};
|
|
571
|
+
/** The session cookie that holds `token`. */
|
|
572
|
+
const sessionCookie = (port, token) => `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
|
|
573
|
+
const exchange = (request, context) => {
|
|
574
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
575
|
+
const given = url.searchParams.get("token");
|
|
576
|
+
if (given === null || request.method !== "GET") return null;
|
|
577
|
+
if (!sameToken(given, context.token)) return {
|
|
578
|
+
kind: "refuse",
|
|
579
|
+
status: 401,
|
|
580
|
+
error: "That link's token is not this server's; open it again with `ork ui`."
|
|
581
|
+
};
|
|
582
|
+
url.searchParams.delete("token");
|
|
583
|
+
return {
|
|
584
|
+
kind: "exchange",
|
|
585
|
+
cookie: sessionCookie(context.port, context.token),
|
|
586
|
+
location: `${url.pathname}${url.search}`
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
/** Decides whether a request may go on. */
|
|
590
|
+
const guard = (request, context) => {
|
|
591
|
+
const host = header(request, "host") ?? "";
|
|
592
|
+
if (!ownOrigins(context.port).includes(`http://${host}`)) return {
|
|
593
|
+
kind: "refuse",
|
|
594
|
+
status: 403,
|
|
595
|
+
error: `Host ${host} is not this server.`
|
|
596
|
+
};
|
|
597
|
+
const exchanged = exchange(request, context);
|
|
598
|
+
if (exchanged !== null) return exchanged;
|
|
599
|
+
const fromBearer = bearer(request);
|
|
600
|
+
const fromCookie = cookieValue(request, cookieName(context.port));
|
|
601
|
+
const given = fromBearer ?? fromCookie;
|
|
602
|
+
if (given === void 0 || !sameToken(given, context.token)) return {
|
|
603
|
+
kind: "refuse",
|
|
604
|
+
status: 401,
|
|
605
|
+
error: "Not signed in; open the UI with `ork ui`."
|
|
606
|
+
};
|
|
607
|
+
if (!WRITES.has(request.method)) return { kind: "pass" };
|
|
608
|
+
const origin = header(request, "origin");
|
|
609
|
+
if ((fromBearer === void 0 || origin !== void 0) && (origin === void 0 || !ownOrigins(context.port).includes(origin))) return {
|
|
610
|
+
kind: "refuse",
|
|
611
|
+
status: 403,
|
|
612
|
+
error: `Origin ${origin ?? "(none)"} may not write here.`
|
|
613
|
+
};
|
|
614
|
+
const type = header(request, "content-type") ?? "";
|
|
615
|
+
if (!/^application\/json\s*(;|$)/iu.test(type)) return {
|
|
616
|
+
kind: "refuse",
|
|
617
|
+
status: 415,
|
|
618
|
+
error: "Send JSON (Content-Type: application/json)."
|
|
619
|
+
};
|
|
620
|
+
return { kind: "pass" };
|
|
621
|
+
};
|
|
622
|
+
/** How often an open event stream gets a keep-alive comment. */
|
|
623
|
+
const KEEP_ALIVE_MS = 15e3;
|
|
624
|
+
/** Sent with every answer: no sniffing, no framing, no referrer, no cache. */
|
|
625
|
+
const BASE_HEADERS = {
|
|
626
|
+
"x-content-type-options": "nosniff",
|
|
627
|
+
"x-frame-options": "DENY",
|
|
628
|
+
"referrer-policy": "no-referrer",
|
|
629
|
+
"cache-control": "no-store"
|
|
630
|
+
};
|
|
631
|
+
const sendJson = (res, status, body, headers = {}) => {
|
|
632
|
+
res.writeHead(status, {
|
|
633
|
+
...BASE_HEADERS,
|
|
634
|
+
...headers,
|
|
635
|
+
"content-type": "application/json; charset=utf-8"
|
|
636
|
+
}).end(JSON.stringify(body));
|
|
637
|
+
};
|
|
638
|
+
const sendError = (res, status, error) => {
|
|
639
|
+
sendJson(res, status, { error });
|
|
640
|
+
};
|
|
641
|
+
/** Reads a JSON body; an empty body reads as `{}`. */
|
|
642
|
+
const readJson = async (req) => {
|
|
643
|
+
const chunks = [];
|
|
644
|
+
let size = 0;
|
|
645
|
+
for await (const chunk of req) {
|
|
646
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
647
|
+
size += buffer.length;
|
|
648
|
+
if (size > 1048576) return {
|
|
649
|
+
ok: false,
|
|
650
|
+
status: 413,
|
|
651
|
+
error: "The body is too large."
|
|
652
|
+
};
|
|
653
|
+
chunks.push(buffer);
|
|
654
|
+
}
|
|
655
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
656
|
+
if (text === "") return {
|
|
657
|
+
ok: true,
|
|
658
|
+
value: {}
|
|
659
|
+
};
|
|
660
|
+
try {
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
value: JSON.parse(text)
|
|
664
|
+
};
|
|
665
|
+
} catch {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
status: 400,
|
|
669
|
+
error: "The body is not valid JSON."
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
/** Opens a Server-Sent Event stream on `res`. */
|
|
674
|
+
const openEventStream = (res, keepAliveMs = KEEP_ALIVE_MS) => {
|
|
675
|
+
const abort = new AbortController();
|
|
676
|
+
res.writeHead(200, {
|
|
677
|
+
...BASE_HEADERS,
|
|
678
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
679
|
+
connection: "keep-alive"
|
|
680
|
+
});
|
|
681
|
+
res.write("retry: 1000\n\n");
|
|
682
|
+
const timer = setInterval(() => {
|
|
683
|
+
res.write(": keep-alive\n\n");
|
|
684
|
+
}, keepAliveMs);
|
|
685
|
+
const close = () => {
|
|
686
|
+
if (abort.signal.aborted) return;
|
|
687
|
+
clearInterval(timer);
|
|
688
|
+
abort.abort();
|
|
689
|
+
res.end();
|
|
690
|
+
};
|
|
691
|
+
res.on("close", close);
|
|
692
|
+
return {
|
|
693
|
+
send: (event, data, id) => {
|
|
694
|
+
if (abort.signal.aborted) return;
|
|
695
|
+
const head = id === void 0 ? "" : `id: ${String(id)}\n`;
|
|
696
|
+
res.write(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
697
|
+
},
|
|
698
|
+
close,
|
|
699
|
+
signal: abort.signal
|
|
700
|
+
};
|
|
701
|
+
};
|
|
702
|
+
// ../server/src/router.ts
|
|
703
|
+
const ok = (body) => ({
|
|
704
|
+
status: 200,
|
|
705
|
+
body
|
|
706
|
+
});
|
|
707
|
+
const accepted = (body) => ({
|
|
708
|
+
status: 202,
|
|
709
|
+
body
|
|
710
|
+
});
|
|
711
|
+
const fail = (status, error) => ({
|
|
712
|
+
status,
|
|
713
|
+
error
|
|
714
|
+
});
|
|
715
|
+
/** A path such as `/api/runs/:id` as a pattern with its param names. */
|
|
716
|
+
const compilePath = (path) => {
|
|
717
|
+
const keys = [];
|
|
718
|
+
const source = path.replaceAll(/:(\w+)/gu, (_, key) => {
|
|
719
|
+
keys.push(key);
|
|
720
|
+
return "([^/]+)";
|
|
721
|
+
});
|
|
722
|
+
return {
|
|
723
|
+
pattern: new RegExp(`^${source}$`, "u"),
|
|
724
|
+
keys
|
|
725
|
+
};
|
|
726
|
+
};
|
|
727
|
+
const isRouteName = (name) => name in API;
|
|
728
|
+
const compile = (name) => {
|
|
729
|
+
const spec = API[name];
|
|
730
|
+
const { pattern, keys } = compilePath(spec.path);
|
|
731
|
+
const compiled = {
|
|
732
|
+
name,
|
|
733
|
+
method: spec.method,
|
|
734
|
+
pattern,
|
|
735
|
+
keys
|
|
736
|
+
};
|
|
737
|
+
if (spec.query !== void 0) compiled.query = spec.query;
|
|
738
|
+
if (spec.body !== void 0) compiled.body = spec.body;
|
|
739
|
+
return compiled;
|
|
740
|
+
};
|
|
741
|
+
const ROUTES = Object.keys(API).filter((name) => isRouteName(name)).map((name) => compile(name));
|
|
742
|
+
const issues = (error) => error.issues.map((i) => i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message).join("; ");
|
|
743
|
+
const parse = (schema, value) => {
|
|
744
|
+
if (schema === void 0) return {
|
|
745
|
+
ok: true,
|
|
746
|
+
value: void 0
|
|
747
|
+
};
|
|
748
|
+
const parsed = schema.safeParse(value);
|
|
749
|
+
return parsed.success ? {
|
|
750
|
+
ok: true,
|
|
751
|
+
value: parsed.data
|
|
752
|
+
} : {
|
|
753
|
+
ok: false,
|
|
754
|
+
error: issues(parsed.error)
|
|
755
|
+
};
|
|
756
|
+
};
|
|
757
|
+
const decode = (value) => {
|
|
758
|
+
try {
|
|
759
|
+
return decodeURIComponent(value);
|
|
760
|
+
} catch {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
/**
|
|
765
|
+
* Answers the request if a JSON route has its path, and returns whether one
|
|
766
|
+
* did; a path with no route for the method gets 405.
|
|
767
|
+
*/
|
|
768
|
+
const route = async (handlers, req, res, url) => {
|
|
769
|
+
const found = ROUTES.map((r) => ({
|
|
770
|
+
r,
|
|
771
|
+
m: r.pattern.exec(url.pathname)
|
|
772
|
+
})).filter(({ m }) => m !== null);
|
|
773
|
+
if (found.length === 0) return false;
|
|
774
|
+
const hit = found.find(({ r }) => r.method === req.method);
|
|
775
|
+
if (hit === void 0 || hit.m === null) {
|
|
776
|
+
sendError(res, 405, `${req.method ?? ""} is not allowed here.`);
|
|
777
|
+
return true;
|
|
778
|
+
}
|
|
779
|
+
const { r, m } = hit;
|
|
780
|
+
const params = {};
|
|
781
|
+
for (const [i, key] of r.keys.entries()) {
|
|
782
|
+
const value = decode(m[i + 1] ?? "");
|
|
783
|
+
if (value === null) {
|
|
784
|
+
sendError(res, 400, `The ${key} in the path is not valid.`);
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
params[key] = value;
|
|
788
|
+
}
|
|
789
|
+
const query = parse(r.query, Object.fromEntries(url.searchParams));
|
|
790
|
+
if (!query.ok) {
|
|
791
|
+
sendError(res, 400, query.error);
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
const read = req.method === "GET" ? {
|
|
795
|
+
ok: true,
|
|
796
|
+
value: void 0
|
|
797
|
+
} : await readJson(req);
|
|
798
|
+
if (!read.ok) {
|
|
799
|
+
sendError(res, read.status, read.error);
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
const body = parse(r.body, read.value);
|
|
803
|
+
if (!body.ok) {
|
|
804
|
+
sendError(res, 400, body.error);
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
const handler = handlers[r.name];
|
|
808
|
+
const reply = await handler({
|
|
809
|
+
params,
|
|
810
|
+
query: query.value,
|
|
811
|
+
body: body.value
|
|
812
|
+
});
|
|
813
|
+
if ("error" in reply) sendError(res, reply.status, reply.error);
|
|
814
|
+
else sendJson(res, reply.status, reply.body);
|
|
815
|
+
return true;
|
|
816
|
+
};
|
|
817
|
+
// ../server/src/context.ts
|
|
818
|
+
/**
|
|
819
|
+
* @file What the server's handlers share: the store it reads and writes, where
|
|
820
|
+
* the sockets are, the `ork` script that detached runs start from, and how a
|
|
821
|
+
* request names a run or a project.
|
|
822
|
+
*/
|
|
823
|
+
/** A run by id or unique prefix: 404 when there is none, 400 when several. */
|
|
824
|
+
const runOf = (context, id) => {
|
|
825
|
+
const found = findRun(context.store, id, "");
|
|
826
|
+
if (found.ok) return found;
|
|
827
|
+
const status = context.store.lookup(id).length === 0 ? 404 : 400;
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
refusal: fail(status, found.error)
|
|
831
|
+
};
|
|
832
|
+
};
|
|
833
|
+
/** A registered project by id or root. */
|
|
834
|
+
const projectOf = (context, key) => context.store.projects().find((p) => p.id === key || p.root === key);
|
|
835
|
+
/** A run as the API shows it: with its live status and what is paused. */
|
|
836
|
+
const viewOf$2 = (context, run) => {
|
|
837
|
+
const events = run.status === "running" ? context.store.events(run.id).map((stored) => stored.event) : [];
|
|
838
|
+
return {
|
|
839
|
+
...run,
|
|
840
|
+
live: runStatus(run, events),
|
|
841
|
+
paused: pausedOf(events)
|
|
842
|
+
};
|
|
843
|
+
};
|
|
844
|
+
// ../server/src/mcp-answers.ts
|
|
845
|
+
/**
|
|
846
|
+
* @file What `ork mcp`'s tools answer with (RFC-012): a route's reply as text,
|
|
847
|
+
* with long strings cut so one answer can't fill a model's context, and the
|
|
848
|
+
* workflow format a part at a time, as the whole is about 50 KB.
|
|
849
|
+
*/
|
|
850
|
+
/** How long a string can be before an answer cuts it. */
|
|
851
|
+
const LONGEST = 400;
|
|
852
|
+
/** A value with every long string cut, saying how much was left out. */
|
|
853
|
+
const shrink = (value, longest = LONGEST) => {
|
|
854
|
+
if (typeof value === "string") return value.length <= longest ? value : `${value.slice(0, longest)}… (${String(value.length - longest)} more characters)`;
|
|
855
|
+
if (Array.isArray(value)) return value.map((item) => shrink(item, longest));
|
|
856
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shrink(item, longest)]));
|
|
857
|
+
return value;
|
|
858
|
+
};
|
|
859
|
+
const answer = (value) => ({ text: JSON.stringify(shrink(value)) });
|
|
860
|
+
const refused$1 = (error) => ({
|
|
861
|
+
text: error,
|
|
862
|
+
isError: true
|
|
863
|
+
});
|
|
864
|
+
/** A route's reply as an answer, `then` shaping what it gave. */
|
|
865
|
+
const fromReply = async (reply, then = (body) => body) => {
|
|
866
|
+
const settled = await reply;
|
|
867
|
+
return "error" in settled ? refused$1(settled.error) : answer(then(settled.body));
|
|
868
|
+
};
|
|
869
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
870
|
+
/** A field of an object, or `undefined`. */
|
|
871
|
+
const at = (value, key) => isRecord$1(value) ? value[key] : void 0;
|
|
872
|
+
/** A node type's name, from its branch of the schema. */
|
|
873
|
+
const typeOf = (branch) => {
|
|
874
|
+
const type = at(at(at(branch, "properties"), "type"), "const");
|
|
875
|
+
return typeof type === "string" ? type : void 0;
|
|
876
|
+
};
|
|
877
|
+
/**
|
|
878
|
+
* The format's JSON Schema: with no node type, the workflow's own fields and
|
|
879
|
+
* the node types there are; with one, that type's full schema.
|
|
880
|
+
*/
|
|
881
|
+
const formatPart = (nodeType) => {
|
|
882
|
+
const schema = JSON.parse(renderJsonSchema());
|
|
883
|
+
const oneOf = at(at(at(at(schema, "properties"), "nodes"), "additionalProperties"), "oneOf");
|
|
884
|
+
const branches = Array.isArray(oneOf) ? oneOf : [];
|
|
885
|
+
const types = branches.flatMap((branch) => typeOf(branch) ?? []);
|
|
886
|
+
const properties = at(schema, "properties");
|
|
887
|
+
if (nodeType === void 0) return answer({
|
|
888
|
+
...isRecord$1(schema) ? schema : {},
|
|
889
|
+
properties: {
|
|
890
|
+
...isRecord$1(properties) ? properties : {},
|
|
891
|
+
nodes: {
|
|
892
|
+
description: "A map of node ids to nodes. Ask for one node type to see its fields.",
|
|
893
|
+
nodeTypes: types
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
const branch = branches.find((item) => typeOf(item) === nodeType);
|
|
898
|
+
return branch === void 0 ? refused$1(`No node type \`${nodeType}\`. The types are: ${types.map((type) => `\`${type}\``).join(", ")}.`) : { text: JSON.stringify(branch) };
|
|
899
|
+
};
|
|
900
|
+
// ../server/src/mcp-tool.ts
|
|
901
|
+
/**
|
|
902
|
+
* @file One of `ork mcp`'s tools (RFC-012): its name, what it says it does, its
|
|
903
|
+
* arguments as a Zod shape, whether it only reads, and its call, which checks
|
|
904
|
+
* the arguments before it runs.
|
|
905
|
+
*/
|
|
906
|
+
const tool = (name, description, shape, run, options = {}) => {
|
|
907
|
+
const schema = z.strictObject(shape);
|
|
908
|
+
return {
|
|
909
|
+
name,
|
|
910
|
+
description,
|
|
911
|
+
shape,
|
|
912
|
+
readOnly: options.readOnly ?? true,
|
|
913
|
+
call: (args) => {
|
|
914
|
+
const parsed = schema.safeParse(args ?? {});
|
|
915
|
+
return Promise.resolve(parsed.success ? run(parsed.data) : refused$1(z.prettifyError(parsed.error)));
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
};
|
|
919
|
+
// ../server/src/write-routes.ts
|
|
920
|
+
/**
|
|
921
|
+
* @file The API's write routes (specs/server/api.md): answer a decision; stop,
|
|
922
|
+
* pause or continue a run or one of its nodes (ADR-041); message a live run's
|
|
923
|
+
* node; and start or resume a run in the background. Each does what its CLI
|
|
924
|
+
* command does (`ork decide --id`, `stop`, `pause`, `continue`, `chat -m`,
|
|
925
|
+
* `run --detach`, `resume --detach`), through the same functions in core:
|
|
926
|
+
* rows in the store, a wake on the run's socket, or a detached process. The
|
|
927
|
+
* server itself runs nothing.
|
|
928
|
+
*/
|
|
929
|
+
/** A refused pause, continue or stop: an unknown node is 404, the rest 409. */
|
|
930
|
+
const commandAnswer = (runId, asked) => {
|
|
931
|
+
if (asked.ok) return accepted({ runId });
|
|
932
|
+
return fail(asked.error.includes("is not a node of") ? 404 : 409, asked.error);
|
|
933
|
+
};
|
|
934
|
+
/** The `ork run` / `ork resume` flags a request's options stand for. */
|
|
935
|
+
const flagsOf = (options) => [
|
|
936
|
+
...Object.entries(options.inputs ?? {}).flatMap(([name, value]) => ["--input", `${name}=${value}`]),
|
|
937
|
+
...options.driver === void 0 ? [] : ["--driver", options.driver],
|
|
938
|
+
...options.inPlace === void 0 ? [] : [options.inPlace ? "--in-place" : "--no-in-place"],
|
|
939
|
+
...options.allowDirty === true ? ["--allow-dirty"] : []
|
|
940
|
+
];
|
|
941
|
+
const WORKFLOW_SUFFIX = ".ork.yaml";
|
|
942
|
+
/**
|
|
943
|
+
* A workflow file of `project`: a name in its workflows folder, or a path
|
|
944
|
+
* inside the project's root or its Ork folder.
|
|
945
|
+
*/
|
|
946
|
+
const workflowFile = (project, workflow) => {
|
|
947
|
+
const workflows = placeFiles(project).workflows;
|
|
948
|
+
return [path.join(workflows, `${workflow}${WORKFLOW_SUFFIX}`), path.resolve(project.root, workflow)].find((file) => (inside(project.root, file) || inside(project.dir, file)) && existsSync(file) && statSync(file).isFile()) ?? null;
|
|
949
|
+
};
|
|
950
|
+
const answerStatus = (error) => {
|
|
951
|
+
if (error.startsWith("No decision")) return 404;
|
|
952
|
+
return error.includes("is not an option") ? 400 : 409;
|
|
953
|
+
};
|
|
954
|
+
const writeRoutes = (context) => {
|
|
955
|
+
const script = context.script === void 0 ? {} : { script: context.script };
|
|
956
|
+
return {
|
|
957
|
+
answer: async ({ params, body }) => {
|
|
958
|
+
const answered = await answerDecision(context.store, params.id, body.choose, context.home, "ui");
|
|
959
|
+
return answered.ok ? ok({
|
|
960
|
+
decision: params.id,
|
|
961
|
+
answer: body.choose
|
|
962
|
+
}) : fail(answerStatus(answered.error), answered.error);
|
|
963
|
+
},
|
|
964
|
+
stop: async ({ params, body }) => {
|
|
965
|
+
const found = runOf(context, params.id);
|
|
966
|
+
if (!found.ok) return found.refusal;
|
|
967
|
+
const { run } = found;
|
|
968
|
+
const { store, home } = context;
|
|
969
|
+
return commandAnswer(run.id, await requestStop(store, run, home, body.node));
|
|
970
|
+
},
|
|
971
|
+
pause: async ({ params, body }) => {
|
|
972
|
+
const found = runOf(context, params.id);
|
|
973
|
+
if (!found.ok) return found.refusal;
|
|
974
|
+
const { run } = found;
|
|
975
|
+
const { store, home } = context;
|
|
976
|
+
return commandAnswer(run.id, await requestPause(store, run, home, body.node));
|
|
977
|
+
},
|
|
978
|
+
continue: async ({ params, body }) => {
|
|
979
|
+
const found = runOf(context, params.id);
|
|
980
|
+
if (!found.ok) return found.refusal;
|
|
981
|
+
const { run } = found;
|
|
982
|
+
const { store, home } = context;
|
|
983
|
+
return commandAnswer(run.id, await requestContinue(store, run, home, body.node));
|
|
984
|
+
},
|
|
985
|
+
message: async ({ params, body }) => {
|
|
986
|
+
const found = runOf(context, params.id);
|
|
987
|
+
if (!found.ok) return found.refusal;
|
|
988
|
+
const { run } = found;
|
|
989
|
+
const { session } = nodeSession(context.store.events(run.id, 0), body.node);
|
|
990
|
+
if (!session.known) return fail(404, `${body.node} is not a node of run ${run.id}.`);
|
|
991
|
+
if (!session.chattable) return fail(400, `${body.node} is not a leader or worker; only they have sessions to talk to.`);
|
|
992
|
+
if (liveStatus(run) !== "running") return fail(409, `Run \`${run.id}\` is not running; to talk to a finished run's node, use \`ork chat ${run.id} ${body.node}\`.`);
|
|
993
|
+
await tellLiveRun(context.store, run, body.node, body.body, context.home);
|
|
994
|
+
return accepted({ runId: run.id });
|
|
995
|
+
},
|
|
996
|
+
link: async ({ body }) => {
|
|
997
|
+
const linked = await linkNodes(context.store, body.a, body.b, {
|
|
998
|
+
maxMessages: body.maxMessages,
|
|
999
|
+
home: context.home
|
|
1000
|
+
});
|
|
1001
|
+
return linked.ok ? ok(linked.link) : fail(409, linked.error);
|
|
1002
|
+
},
|
|
1003
|
+
unlink: async ({ body }) => {
|
|
1004
|
+
const ended = await unlinkNodes(context.store, body.a, body.b, context.home);
|
|
1005
|
+
return ended.ok ? ok({ ended: true }) : fail(404, ended.error);
|
|
1006
|
+
},
|
|
1007
|
+
start: async ({ body }) => {
|
|
1008
|
+
const request = body;
|
|
1009
|
+
const project = projectOf(context, request.project);
|
|
1010
|
+
if (project === void 0) return fail(404, `No registered project \`${request.project}\`; register it with \`ork init\` or \`ork register\`.`);
|
|
1011
|
+
const file = workflowFile(project, request.workflow);
|
|
1012
|
+
if (file === null) return fail(404, `No workflow \`${request.workflow}\` in \`${project.id}\`'s workflows or inside its root.`);
|
|
1013
|
+
const runId = newRunId(context.store);
|
|
1014
|
+
const detached = await detachRun({
|
|
1015
|
+
store: context.store,
|
|
1016
|
+
runId,
|
|
1017
|
+
args: [
|
|
1018
|
+
"run",
|
|
1019
|
+
file,
|
|
1020
|
+
...flagsOf(request),
|
|
1021
|
+
"--run-id",
|
|
1022
|
+
runId
|
|
1023
|
+
],
|
|
1024
|
+
cwd: project.root,
|
|
1025
|
+
runDir: runDirOf(project, runId),
|
|
1026
|
+
...script
|
|
1027
|
+
});
|
|
1028
|
+
return detached.ok ? accepted({ runId }) : fail(400, detached.error || "The run did not start.");
|
|
1029
|
+
},
|
|
1030
|
+
resume: async ({ params, body }) => {
|
|
1031
|
+
const found = runOf(context, params.id);
|
|
1032
|
+
if (!found.ok) return found.refusal;
|
|
1033
|
+
const ready = resumable(context.store, found.run, {
|
|
1034
|
+
...body.inPlace === void 0 ? {} : { inPlace: body.inPlace },
|
|
1035
|
+
...body.driver === void 0 ? {} : { driver: body.driver }
|
|
1036
|
+
});
|
|
1037
|
+
if (!ready.ok) return fail(409, ready.error);
|
|
1038
|
+
const detached = await detachResume(context.store, found.run, flagsOf(body), context.script);
|
|
1039
|
+
return detached.ok ? accepted({ runId: found.run.id }) : fail(400, detached.error || "The run did not resume.");
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
// ../server/src/mcp-act.ts
|
|
1044
|
+
/**
|
|
1045
|
+
* @file The tools `ork mcp` gives Claude Code beyond reading (RFC-012):
|
|
1046
|
+
* `check`, which checks a draft or a file as `ork validate` does, walk and
|
|
1047
|
+
* all, and `start_run`, which starts a run in the background as `ork run
|
|
1048
|
+
* --detach` does. Neither answers a decision; starting is the one that
|
|
1049
|
+
* spends, and Claude Code asks the person before each.
|
|
1050
|
+
*/
|
|
1051
|
+
/** How many of a walk's endings `check` lists when asked for them. */
|
|
1052
|
+
const MOST_ENDINGS = 20;
|
|
1053
|
+
const project$1 = z.string().min(1).describe("A registered project's id or root");
|
|
1054
|
+
/** The text to check, and the file whose folder its paths resolve from. */
|
|
1055
|
+
const textOf = (context, key, args) => {
|
|
1056
|
+
const place = projectOf(context, key);
|
|
1057
|
+
if (place === void 0) return {
|
|
1058
|
+
ok: false,
|
|
1059
|
+
answer: refused$1(`No registered project \`${key}\`.`)
|
|
1060
|
+
};
|
|
1061
|
+
if (args.workflow === void 0 === (args.text === void 0)) return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
answer: refused$1("Give either `workflow` (a name or path) or `text`.")
|
|
1064
|
+
};
|
|
1065
|
+
if (args.text !== void 0) {
|
|
1066
|
+
const file = path.join(placeFiles(place).workflows, "draft.ork.yaml");
|
|
1067
|
+
return {
|
|
1068
|
+
ok: true,
|
|
1069
|
+
text: args.text,
|
|
1070
|
+
file,
|
|
1071
|
+
place
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
const file = workflowFile(place, args.workflow ?? "");
|
|
1075
|
+
return file === null ? {
|
|
1076
|
+
ok: false,
|
|
1077
|
+
answer: refused$1(`No workflow \`${args.workflow ?? ""}\` in \`${place.id}\`'s workflows or inside its root.`)
|
|
1078
|
+
} : {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
text: readFileSync(file, "utf8"),
|
|
1081
|
+
file,
|
|
1082
|
+
place
|
|
1083
|
+
};
|
|
1084
|
+
};
|
|
1085
|
+
const checkTool = (context) => tool("check", "Checks a workflow as `ork validate` does: a draft's `text`, or a `workflow` by name or path. It gives each problem with its line, then walks every way through without running anything. Ask for `paths` to see how a run can end.", {
|
|
1086
|
+
project: project$1,
|
|
1087
|
+
workflow: z.string().min(1).optional(),
|
|
1088
|
+
text: z.string().min(1).optional(),
|
|
1089
|
+
paths: z.boolean().optional()
|
|
1090
|
+
}, async (args) => {
|
|
1091
|
+
const found = textOf(context, args.project, args);
|
|
1092
|
+
if (!found.ok) return found.answer;
|
|
1093
|
+
const { diagnostics, walk } = await walkChecks(checkWorkflowText(found.text, {
|
|
1094
|
+
place: found.place,
|
|
1095
|
+
workflowDir: workflowDirOf(found.place, found.file),
|
|
1096
|
+
registered: context.store.projects(),
|
|
1097
|
+
...context.home === void 0 ? {} : { home: context.home }
|
|
1098
|
+
}));
|
|
1099
|
+
const count = (severity) => diagnostics.filter((each) => each.severity === severity).length;
|
|
1100
|
+
return answer({
|
|
1101
|
+
errors: count("error"),
|
|
1102
|
+
warnings: count("warning"),
|
|
1103
|
+
diagnostics: diagnostics.map(({ severity, code, message, line, column }) => line === void 0 ? {
|
|
1104
|
+
severity,
|
|
1105
|
+
code,
|
|
1106
|
+
message
|
|
1107
|
+
} : {
|
|
1108
|
+
severity,
|
|
1109
|
+
code,
|
|
1110
|
+
message,
|
|
1111
|
+
line,
|
|
1112
|
+
column
|
|
1113
|
+
}),
|
|
1114
|
+
walk: walk === void 0 ? null : walkSummary(walk),
|
|
1115
|
+
...args.paths === true && walk !== void 0 ? { endings: walk.endings.slice(0, MOST_ENDINGS) } : {}
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
const startTool = (context) => {
|
|
1119
|
+
const { start } = writeRoutes(context);
|
|
1120
|
+
return tool("start_run", "Starts a workflow in the background, as `ork run --detach` does, and gives its run id. It spends money, and its questions wait on a person, who answers in `ork ui` or with `ork decide`.", {
|
|
1121
|
+
project: project$1,
|
|
1122
|
+
workflow: z.string().min(1).describe("A workflow's name, or a path"),
|
|
1123
|
+
inputs: z.record(z.string(), z.string()).optional()
|
|
1124
|
+
}, (args) => fromReply(start({
|
|
1125
|
+
params: {},
|
|
1126
|
+
query: void 0,
|
|
1127
|
+
body: args
|
|
1128
|
+
}), (started) => ({
|
|
1129
|
+
...started,
|
|
1130
|
+
next: "Follow it with `run` and `run_events`. A decision it opens waits on a person."
|
|
1131
|
+
})), { readOnly: false });
|
|
1132
|
+
};
|
|
1133
|
+
/** `check`, and `start_run` where starting is allowed. */
|
|
1134
|
+
const actingTools = (context, options) => [checkTool(context), ...options.start ? [startTool(context)] : []];
|
|
1135
|
+
// ../server/src/builder-files.ts
|
|
1136
|
+
/**
|
|
1137
|
+
* @file A workflow's file for the builder (RFC-008): reading it only where it
|
|
1138
|
+
* stays inside the project, and writing it, the one place the server writes
|
|
1139
|
+
* into a project, whole or not at all and never through a link.
|
|
1140
|
+
*/
|
|
1141
|
+
/** Most file systems stop at 255 bytes, and the suffix takes some of those. */
|
|
1142
|
+
const LONGEST_NAME = 200;
|
|
1143
|
+
/** A name that stays one file in the workflows folder. */
|
|
1144
|
+
const safeName = (name) => name.length > 0 && Buffer.byteLength(name) <= LONGEST_NAME && path.basename(name) === name && !name.startsWith(".") && !name.includes("\\") && !name.includes("\0");
|
|
1145
|
+
/** Call only once `unreadableReason` has passed the file. */
|
|
1146
|
+
const readText = (file) => existsSync(file) ? readFileSync(file, "utf8") : void 0;
|
|
1147
|
+
const hashOf = (text) => createHash("sha256").update(text).digest("hex");
|
|
1148
|
+
/** A file by its contents, or `null` when there's none. */
|
|
1149
|
+
const hashOfFile = (file) => {
|
|
1150
|
+
const text = readText(file);
|
|
1151
|
+
return text === void 0 ? null : hashOf(text);
|
|
1152
|
+
};
|
|
1153
|
+
/** The folder a project's own files must stay in, links resolved. */
|
|
1154
|
+
const homeOf = (project) => realpathSync(inside(project.root, project.dir) ? project.root : project.dir);
|
|
1155
|
+
/** Whether something is at `at`, even a link that leads nowhere. */
|
|
1156
|
+
const isThere = (at) => lstatSync(at, { throwIfNoEntry: false }) !== void 0;
|
|
1157
|
+
/** The nearest existing folder on the way to `file`. */
|
|
1158
|
+
const nearestFolder = (file) => {
|
|
1159
|
+
let folder = path.dirname(file);
|
|
1160
|
+
while (!isThere(folder) && path.dirname(folder) !== folder) folder = path.dirname(folder);
|
|
1161
|
+
return folder;
|
|
1162
|
+
};
|
|
1163
|
+
const folderOutside = (project, file) => {
|
|
1164
|
+
const folder = nearestFolder(file);
|
|
1165
|
+
return !existsSync(folder) || !existsSync(project.root) || !existsSync(project.dir) || !inside(homeOf(project), realpathSync(folder));
|
|
1166
|
+
};
|
|
1167
|
+
const LEADS_OUT = "The workflows folder leads out of the project, so Ork won't use it.";
|
|
1168
|
+
/** Why this file may not be read; a link could lead out of the project. */
|
|
1169
|
+
const unreadableReason = (project, file) => {
|
|
1170
|
+
if (folderOutside(project, file)) return LEADS_OUT;
|
|
1171
|
+
if (!isThere(file)) return void 0;
|
|
1172
|
+
if (!existsSync(file) || !inside(homeOf(project), realpathSync(file))) return `${path.basename(file)} is a link that leads out of the project, so Ork won't read it.`;
|
|
1173
|
+
return statSync(file).isFile() ? void 0 : `${path.basename(file)} isn't a file.`;
|
|
1174
|
+
};
|
|
1175
|
+
/** Why this file may not be written: it must not be a link at all. */
|
|
1176
|
+
const outsideReason = (project, file) => {
|
|
1177
|
+
if (folderOutside(project, file)) return LEADS_OUT;
|
|
1178
|
+
const found = lstatSync(file, { throwIfNoEntry: false });
|
|
1179
|
+
if (found?.isSymbolicLink() === true) return `${path.basename(file)} is a symbolic link, so Ork won't write through it.`;
|
|
1180
|
+
return found === void 0 || found.isFile() ? void 0 : `${path.basename(file)} isn't a file.`;
|
|
1181
|
+
};
|
|
1182
|
+
/** Writes `text` so that a reader sees the old file or the new, never part. */
|
|
1183
|
+
const writeWhole = (file, text) => {
|
|
1184
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1185
|
+
const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
1186
|
+
writeFileSync(temporary, text, { flag: "wx" });
|
|
1187
|
+
try {
|
|
1188
|
+
if (existsSync(file)) chmodSync(temporary, statSync(file).mode);
|
|
1189
|
+
renameSync(temporary, file);
|
|
1190
|
+
} catch (failed) {
|
|
1191
|
+
rmSync(temporary, { force: true });
|
|
1192
|
+
throw failed;
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
// ../server/src/builder-graph.ts
|
|
1196
|
+
/**
|
|
1197
|
+
* @file The graph of a draft (RFC-008). A draft isn't valid until it's
|
|
1198
|
+
* finished, so this reads what the text says rather than the parsed workflow:
|
|
1199
|
+
* a node with no type yet still gets a card, and an edge that can't be read
|
|
1200
|
+
* is left for the checks to report.
|
|
1201
|
+
*/
|
|
1202
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1203
|
+
const asArray = (value) => Array.isArray(value) ? value : [];
|
|
1204
|
+
const EMPTY = {
|
|
1205
|
+
nodes: [],
|
|
1206
|
+
edges: [],
|
|
1207
|
+
channels: []
|
|
1208
|
+
};
|
|
1209
|
+
/** The nodes finished enough to say what they'd report. */
|
|
1210
|
+
const validNodes = (document) => Object.fromEntries(Object.entries(isRecord(document["nodes"]) ? document["nodes"] : {}).flatMap(([id, node]) => {
|
|
1211
|
+
const parsed = NodeSchema.safeParse(node);
|
|
1212
|
+
return parsed.success ? [[id, parsed.data]] : [];
|
|
1213
|
+
}));
|
|
1214
|
+
/** What the text says, and the graph to draw from it. */
|
|
1215
|
+
const draftGraph = (text) => {
|
|
1216
|
+
const document = readDocument(text);
|
|
1217
|
+
if (!isRecord(document)) return {
|
|
1218
|
+
document: document ?? null,
|
|
1219
|
+
graph: EMPTY
|
|
1220
|
+
};
|
|
1221
|
+
const nodes = isRecord(document["nodes"]) ? Object.entries(document["nodes"]).map(([id, node]) => ({
|
|
1222
|
+
id,
|
|
1223
|
+
type: isRecord(node) && typeof node["type"] === "string" ? node["type"] : "",
|
|
1224
|
+
parent: isRecord(node) && typeof node["parent"] === "string" ? node["parent"] : void 0
|
|
1225
|
+
})) : [];
|
|
1226
|
+
const edges = asArray(document["edges"]).flatMap((input, index) => {
|
|
1227
|
+
const parsed = EdgeInputSchema.safeParse(input);
|
|
1228
|
+
if (!parsed.success) return [];
|
|
1229
|
+
const expanded = expandEdge(parsed.data, index);
|
|
1230
|
+
return expanded.ok ? expanded.edges.map(({ from, to, when, maxRounds }) => ({
|
|
1231
|
+
from,
|
|
1232
|
+
to,
|
|
1233
|
+
when,
|
|
1234
|
+
maxRounds,
|
|
1235
|
+
condition: readCondition(when)
|
|
1236
|
+
})) : [];
|
|
1237
|
+
});
|
|
1238
|
+
const channels = asArray(document["messages"]).flatMap((channel) => {
|
|
1239
|
+
const [a, b] = isRecord(channel) ? asArray(channel["between"]) : [];
|
|
1240
|
+
return typeof a === "string" && typeof b === "string" ? [[a, b]] : [];
|
|
1241
|
+
});
|
|
1242
|
+
const untils = isRecord(document["nodes"]) ? Object.entries(document["nodes"]).flatMap(([id, node]) => {
|
|
1243
|
+
const watch = isRecord(node) ? node["watch"] : void 0;
|
|
1244
|
+
const until = isRecord(watch) ? watch["until"] : void 0;
|
|
1245
|
+
return typeof until === "string" ? [[id, readCondition(until)]] : [];
|
|
1246
|
+
}) : [];
|
|
1247
|
+
return {
|
|
1248
|
+
document,
|
|
1249
|
+
graph: {
|
|
1250
|
+
nodes,
|
|
1251
|
+
edges,
|
|
1252
|
+
channels,
|
|
1253
|
+
says: nodeSays(validNodes(document), edges),
|
|
1254
|
+
untils: Object.fromEntries(untils)
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
};
|
|
1258
|
+
// ../server/src/builder-view.ts
|
|
1259
|
+
/**
|
|
1260
|
+
* @file What the builder shows for a workflow's text (RFC-008): what it says,
|
|
1261
|
+
* its graph, what the checks say, and what walking it found (RFC-009), which
|
|
1262
|
+
* never stops a save.
|
|
1263
|
+
*/
|
|
1264
|
+
const SUFFIX$1 = ".ork.yaml";
|
|
1265
|
+
const fileOf = (project, name) => path.join(placeFiles(project).workflows, `${name}${SUFFIX$1}`);
|
|
1266
|
+
/** The workflow as it stands: its draft, or else its file. */
|
|
1267
|
+
const viewOf$1 = async (context, project, name, text) => {
|
|
1268
|
+
const file = fileOf(project, name);
|
|
1269
|
+
const { diagnostics, workflow, walk } = await walkChecks(checkWorkflowText(text, {
|
|
1270
|
+
place: project,
|
|
1271
|
+
workflowDir: workflowDirOf(project, file),
|
|
1272
|
+
registered: context.store.projects(),
|
|
1273
|
+
home: context.home
|
|
1274
|
+
}), { maxMs: 200 });
|
|
1275
|
+
const draft = context.store.draft(project.id, name);
|
|
1276
|
+
const onDisk = hashOfFile(file);
|
|
1277
|
+
const graph = draftGraph(text);
|
|
1278
|
+
return {
|
|
1279
|
+
name,
|
|
1280
|
+
file,
|
|
1281
|
+
text,
|
|
1282
|
+
document: graph.document,
|
|
1283
|
+
graph: workflow === null ? graph.graph : {
|
|
1284
|
+
...graph.graph,
|
|
1285
|
+
says: nodeSays(workflow.nodes, graph.graph.edges)
|
|
1286
|
+
},
|
|
1287
|
+
diagnostics,
|
|
1288
|
+
walk: walk === void 0 ? null : walkSummary(walk),
|
|
1289
|
+
draft: draft !== void 0,
|
|
1290
|
+
baseHash: draft === void 0 ? onDisk : draft.baseHash,
|
|
1291
|
+
changedOnDisk: draft !== void 0 && onDisk !== draft.baseHash
|
|
1292
|
+
};
|
|
1293
|
+
};
|
|
1294
|
+
// ../server/src/builder.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* @file The workflow builder's files and drafts (RFC-008): what a workflow is
|
|
1297
|
+
* while it's edited, applying edits to its draft, and saving it. This is the
|
|
1298
|
+
* only place the server writes a project's files, and it writes only a valid
|
|
1299
|
+
* workflow, inside the project's workflows folder.
|
|
1300
|
+
*/
|
|
1301
|
+
const notFound = (project, name) => ({
|
|
1302
|
+
refused: 404,
|
|
1303
|
+
error: `No workflow \`${name}\` in \`${project.id}\`'s workflows.`
|
|
1304
|
+
});
|
|
1305
|
+
const badName = (name) => ({
|
|
1306
|
+
refused: 400,
|
|
1307
|
+
error: `\`${name}\` can't be a workflow's file name.`
|
|
1308
|
+
});
|
|
1309
|
+
/** Why this workflow can't be touched at all, by its name or where it leads. */
|
|
1310
|
+
const problemWith = (project, name) => {
|
|
1311
|
+
if (!safeName(name)) return badName(name);
|
|
1312
|
+
const reason = unreadableReason(project, fileOf(project, name));
|
|
1313
|
+
return reason === void 0 ? void 0 : {
|
|
1314
|
+
refused: 409,
|
|
1315
|
+
error: reason
|
|
1316
|
+
};
|
|
1317
|
+
};
|
|
1318
|
+
/** What the builder shows for a workflow. */
|
|
1319
|
+
const builderView = async (context, project, name) => {
|
|
1320
|
+
const problem = problemWith(project, name);
|
|
1321
|
+
if (problem !== void 0) return problem;
|
|
1322
|
+
const text = context.store.draft(project.id, name)?.text ?? readText(fileOf(project, name));
|
|
1323
|
+
if (text === void 0) return notFound(project, name);
|
|
1324
|
+
return await viewOf$1(context, project, name, text);
|
|
1325
|
+
};
|
|
1326
|
+
const NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
1327
|
+
/** Starts a draft of a new workflow. */
|
|
1328
|
+
const createDraft = async (context, project, name) => {
|
|
1329
|
+
if (!NAME.test(name) || !safeName(name)) return {
|
|
1330
|
+
refused: 400,
|
|
1331
|
+
error: "A workflow's name is a lowercase slug, such as nightly-review."
|
|
1332
|
+
};
|
|
1333
|
+
const problem = problemWith(project, name);
|
|
1334
|
+
if (problem !== void 0) return problem;
|
|
1335
|
+
if (existsSync(fileOf(project, name)) || context.store.draft(project.id, name) !== void 0) return {
|
|
1336
|
+
refused: 409,
|
|
1337
|
+
error: `There is already a workflow \`${name}\`.`
|
|
1338
|
+
};
|
|
1339
|
+
const text = newWorkflowText(name);
|
|
1340
|
+
context.store.putDraft({
|
|
1341
|
+
project: project.id,
|
|
1342
|
+
workflow: name,
|
|
1343
|
+
text,
|
|
1344
|
+
baseHash: null,
|
|
1345
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1346
|
+
});
|
|
1347
|
+
return await viewOf$1(context, project, name, text);
|
|
1348
|
+
};
|
|
1349
|
+
/**
|
|
1350
|
+
* Stores new text as the draft, or drops it if it's back to the file's. A draft
|
|
1351
|
+
* remembers the file it began from; `baseHash` says so when there's no draft
|
|
1352
|
+
* yet to remember it, and otherwise it's the file as it is now.
|
|
1353
|
+
*/
|
|
1354
|
+
const keep = (context, project, name, text, baseHash) => {
|
|
1355
|
+
const file = fileOf(project, name);
|
|
1356
|
+
const draft = context.store.draft(project.id, name);
|
|
1357
|
+
if (text === readText(file)) {
|
|
1358
|
+
context.store.dropDraft(project.id, name);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
context.store.putDraft({
|
|
1362
|
+
project: project.id,
|
|
1363
|
+
workflow: name,
|
|
1364
|
+
text,
|
|
1365
|
+
baseHash: draft === void 0 ? baseHash === void 0 ? hashOfFile(file) : baseHash : draft.baseHash,
|
|
1366
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1367
|
+
});
|
|
1368
|
+
};
|
|
1369
|
+
/** Applies edits to the draft, starting one from the file if there's none. */
|
|
1370
|
+
const editDraft = async (context, project, name, edits) => {
|
|
1371
|
+
const problem = problemWith(project, name);
|
|
1372
|
+
if (problem !== void 0) return problem;
|
|
1373
|
+
const checked = WorkflowEditSchema.array().safeParse(edits);
|
|
1374
|
+
if (!checked.success) return {
|
|
1375
|
+
refused: 400,
|
|
1376
|
+
error: checked.error.issues[0]?.message ?? "That isn't an edit."
|
|
1377
|
+
};
|
|
1378
|
+
const draft = context.store.draft(project.id, name);
|
|
1379
|
+
const file = fileOf(project, name);
|
|
1380
|
+
const text = draft?.text ?? readText(file);
|
|
1381
|
+
if (text === void 0) return notFound(project, name);
|
|
1382
|
+
const result = applyEdits(text, checked.data);
|
|
1383
|
+
if (!result.ok) return {
|
|
1384
|
+
refused: 409,
|
|
1385
|
+
error: result.message
|
|
1386
|
+
};
|
|
1387
|
+
const previous = {
|
|
1388
|
+
text,
|
|
1389
|
+
baseHash: draft === void 0 ? hashOfFile(file) : draft.baseHash
|
|
1390
|
+
};
|
|
1391
|
+
keep(context, project, name, result.text);
|
|
1392
|
+
return {
|
|
1393
|
+
...await viewOf$1(context, project, name, result.text),
|
|
1394
|
+
previous
|
|
1395
|
+
};
|
|
1396
|
+
};
|
|
1397
|
+
/** Puts a draft back to earlier text: the builder's undo. */
|
|
1398
|
+
const restoreDraft = async (context, project, name, text, baseHash) => {
|
|
1399
|
+
const problem = problemWith(project, name);
|
|
1400
|
+
if (problem !== void 0) return problem;
|
|
1401
|
+
if (!(context.store.draft(project.id, name) !== void 0 || existsSync(fileOf(project, name)))) return notFound(project, name);
|
|
1402
|
+
keep(context, project, name, text, baseHash);
|
|
1403
|
+
return await viewOf$1(context, project, name, text);
|
|
1404
|
+
};
|
|
1405
|
+
/** Throws away a draft; what's left is the file, if there is one. */
|
|
1406
|
+
const discardDraft = async (context, project, name) => {
|
|
1407
|
+
if (!safeName(name)) return badName(name);
|
|
1408
|
+
context.store.dropDraft(project.id, name);
|
|
1409
|
+
const problem = problemWith(project, name);
|
|
1410
|
+
if (problem !== void 0) return problem;
|
|
1411
|
+
const text = readText(fileOf(project, name));
|
|
1412
|
+
if (text === void 0) return { gone: true };
|
|
1413
|
+
return await viewOf$1(context, project, name, text);
|
|
1414
|
+
};
|
|
1415
|
+
/**
|
|
1416
|
+
* Saves a draft to the workflows folder. Errors block it, as a running
|
|
1417
|
+
* workflow's call could read the file, and so does a file that changed since
|
|
1418
|
+
* the draft began, unless the user chooses to replace it.
|
|
1419
|
+
*/
|
|
1420
|
+
const saveDraft = (context, project, name, force) => {
|
|
1421
|
+
if (!safeName(name)) return badName(name);
|
|
1422
|
+
const file = fileOf(project, name);
|
|
1423
|
+
const outside = outsideReason(project, file);
|
|
1424
|
+
if (outside !== void 0) return {
|
|
1425
|
+
refused: 409,
|
|
1426
|
+
error: outside
|
|
1427
|
+
};
|
|
1428
|
+
const draft = context.store.draft(project.id, name);
|
|
1429
|
+
if (draft === void 0) return existsSync(file) ? {
|
|
1430
|
+
saved: true,
|
|
1431
|
+
file
|
|
1432
|
+
} : notFound(project, name);
|
|
1433
|
+
const { diagnostics } = checkWorkflowText(draft.text, {
|
|
1434
|
+
place: project,
|
|
1435
|
+
workflowDir: workflowDirOf(project, file),
|
|
1436
|
+
registered: context.store.projects(),
|
|
1437
|
+
home: context.home
|
|
1438
|
+
});
|
|
1439
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
1440
|
+
if (errors > 0) return {
|
|
1441
|
+
saved: false,
|
|
1442
|
+
why: "errors",
|
|
1443
|
+
message: `Fix ${errors === 1 ? "the error" : `the ${String(errors)} errors`} first; nothing was saved.`
|
|
1444
|
+
};
|
|
1445
|
+
if (!force && hashOfFile(file) !== draft.baseHash) return {
|
|
1446
|
+
saved: false,
|
|
1447
|
+
why: "changed",
|
|
1448
|
+
message: `${path.basename(file)} changed since this draft began; nothing was saved.`
|
|
1449
|
+
};
|
|
1450
|
+
writeWhole(file, draft.text);
|
|
1451
|
+
context.store.dropDraft(project.id, name);
|
|
1452
|
+
return {
|
|
1453
|
+
saved: true,
|
|
1454
|
+
file
|
|
1455
|
+
};
|
|
1456
|
+
};
|
|
1457
|
+
// ../server/src/builder-routes.ts
|
|
1458
|
+
/**
|
|
1459
|
+
* @file The workflow builder's routes (RFC-008, specs/server/api.md). Each
|
|
1460
|
+
* finds the project, then does its work in `builder.ts`, and a refusal has
|
|
1461
|
+
* the words the user will see.
|
|
1462
|
+
*/
|
|
1463
|
+
const isRefused = (answer) => "refused" in answer;
|
|
1464
|
+
const replyOf = (answer) => isRefused(answer) ? fail(answer.refused, answer.error) : ok(answer);
|
|
1465
|
+
const builderRoutes = (context) => {
|
|
1466
|
+
/** The answer for a registered project; a 404 for any other. */
|
|
1467
|
+
const inProject = (id, work) => {
|
|
1468
|
+
const project = projectOf(context, id);
|
|
1469
|
+
return project === void 0 ? fail(404, `No registered project \`${id}\`.`) : replyOf(work(project));
|
|
1470
|
+
};
|
|
1471
|
+
/** The same, for work the server walks a workflow for. */
|
|
1472
|
+
const walkedInProject = async (id, work) => {
|
|
1473
|
+
const project = projectOf(context, id);
|
|
1474
|
+
return project === void 0 ? fail(404, `No registered project \`${id}\`.`) : replyOf(await work(project));
|
|
1475
|
+
};
|
|
1476
|
+
return {
|
|
1477
|
+
builderSchema: () => ok(JSON.parse(renderJsonSchema())),
|
|
1478
|
+
builderCatalog: ({ params }) => inProject(params.id, (project) => {
|
|
1479
|
+
const home = context.home ?? homedir();
|
|
1480
|
+
return {
|
|
1481
|
+
agents: loadAgents({
|
|
1482
|
+
projectRoot: project.root,
|
|
1483
|
+
orkDir: project.dir,
|
|
1484
|
+
home
|
|
1485
|
+
}).agents.map(({ name, source, agent }) => ({
|
|
1486
|
+
name,
|
|
1487
|
+
description: agent.description ?? "",
|
|
1488
|
+
source
|
|
1489
|
+
})),
|
|
1490
|
+
skills: [...skillsIn(path.join(home, ".ork", "skills"), "user"), ...skillsIn(path.join(project.root, ".claude", "skills"), "project")].map(({ name, layer }) => ({
|
|
1491
|
+
name,
|
|
1492
|
+
layer
|
|
1493
|
+
}))
|
|
1494
|
+
};
|
|
1495
|
+
}),
|
|
1496
|
+
builderDrafts: ({ params }) => inProject(params.id, (project) => {
|
|
1497
|
+
return context.store.drafts(project.id).map(({ workflow }) => workflow);
|
|
1498
|
+
}),
|
|
1499
|
+
builderView: ({ params }) => walkedInProject(params.id, (project) => builderView(context, project, params.name)),
|
|
1500
|
+
builderCreate: ({ params, body }) => walkedInProject(params.id, (project) => createDraft(context, project, body.name)),
|
|
1501
|
+
builderEdit: ({ params, body }) => walkedInProject(params.id, (project) => editDraft(context, project, params.name, body.edits)),
|
|
1502
|
+
builderRestore: ({ params, body }) => walkedInProject(params.id, (project) => restoreDraft(context, project, params.name, body.text, body.baseHash)),
|
|
1503
|
+
builderDiscard: ({ params }) => walkedInProject(params.id, (project) => discardDraft(context, project, params.name)),
|
|
1504
|
+
builderSave: ({ params, body }) => inProject(params.id, (project) => saveDraft(context, project, params.name, body.force === true))
|
|
1505
|
+
};
|
|
1506
|
+
};
|
|
1507
|
+
// ../server/src/workflows.ts
|
|
1508
|
+
/**
|
|
1509
|
+
* @file A project's workflows, for the UI's Run workflow dialog: each
|
|
1510
|
+
* `*.ork.yaml` in its workflows folder, with its name, description and
|
|
1511
|
+
* declared inputs; one that does not parse is listed with why, since starting
|
|
1512
|
+
* it is how the user sees the whole error.
|
|
1513
|
+
*/
|
|
1514
|
+
const SUFFIX = ".ork.yaml";
|
|
1515
|
+
const viewOf = (file) => {
|
|
1516
|
+
const name = path.basename(file).slice(0, -9);
|
|
1517
|
+
let text;
|
|
1518
|
+
try {
|
|
1519
|
+
text = readFileSync(file, "utf8");
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
return {
|
|
1522
|
+
name,
|
|
1523
|
+
file,
|
|
1524
|
+
inputs: {},
|
|
1525
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
const parsed = parseWorkflow(text);
|
|
1529
|
+
if (!parsed.ok) {
|
|
1530
|
+
const [first] = parsed.diagnostics;
|
|
1531
|
+
return {
|
|
1532
|
+
name,
|
|
1533
|
+
file,
|
|
1534
|
+
inputs: {},
|
|
1535
|
+
error: first?.message ?? "It is not a valid workflow."
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
const { workflow } = parsed;
|
|
1539
|
+
return {
|
|
1540
|
+
name,
|
|
1541
|
+
file,
|
|
1542
|
+
title: workflow.name,
|
|
1543
|
+
...workflow.description === void 0 ? {} : { description: workflow.description },
|
|
1544
|
+
inputs: workflow.inputs ?? {}
|
|
1545
|
+
};
|
|
1546
|
+
};
|
|
1547
|
+
/** The workflows in `project`'s workflows folder, by name. */
|
|
1548
|
+
const projectWorkflows = (project) => {
|
|
1549
|
+
const folder = placeFiles(project).workflows;
|
|
1550
|
+
let names;
|
|
1551
|
+
try {
|
|
1552
|
+
names = readdirSync(folder);
|
|
1553
|
+
} catch {
|
|
1554
|
+
return [];
|
|
1555
|
+
}
|
|
1556
|
+
return names.filter((name) => name.endsWith(SUFFIX)).toSorted().map((name) => viewOf(path.join(folder, name)));
|
|
1557
|
+
};
|
|
1558
|
+
// ../server/src/read-routes.ts
|
|
1559
|
+
/**
|
|
1560
|
+
* @file The API's read routes (specs/server/api.md): health, projects and a
|
|
1561
|
+
* project's workflows, runs, a run, its tree and its events, the open
|
|
1562
|
+
* decisions, and usage. Each reads the store as the matching CLI command does
|
|
1563
|
+
* (`ork projects`, `ps`, `tree`, `logs`, `decide --list`, `usage`).
|
|
1564
|
+
*/
|
|
1565
|
+
/** A project filter's root: a registered project's, or the value as a root. */
|
|
1566
|
+
/** A registered project's place; caps are only known for one. */
|
|
1567
|
+
const placeOf = (context, key) => {
|
|
1568
|
+
const known = key === void 0 ? void 0 : projectOf(context, key);
|
|
1569
|
+
return known === void 0 ? void 0 : placeFor(known.root, context.store.projects());
|
|
1570
|
+
};
|
|
1571
|
+
const projectRoot = (context, key) => projectOf(context, key)?.root ?? key;
|
|
1572
|
+
const readRoutes = (context) => ({
|
|
1573
|
+
health: () => ok({
|
|
1574
|
+
version: context.version,
|
|
1575
|
+
pid: context.pid,
|
|
1576
|
+
startedAt: context.startedAt
|
|
1577
|
+
}),
|
|
1578
|
+
projects: () => ok(context.store.projects()),
|
|
1579
|
+
triggers: () => ok(triggersReport(context.store, context.store.projects(), context.home)),
|
|
1580
|
+
links: ({ query }) => ok(context.store.links(query.run === void 0 ? {} : { run: query.run })),
|
|
1581
|
+
usage: ({ query }) => ok({
|
|
1582
|
+
...usageReport(context.store.usage({
|
|
1583
|
+
...query.project === void 0 ? {} : { project: projectRoot(context, query.project) },
|
|
1584
|
+
...query.workflow === void 0 ? {} : { workflow: query.workflow },
|
|
1585
|
+
...query.since === void 0 ? {} : { since: query.since },
|
|
1586
|
+
...query.until === void 0 ? {} : { until: query.until }
|
|
1587
|
+
})),
|
|
1588
|
+
limits: limitsReport(context.store, capsHere(context.home, placeOf(context, query.project)), /* @__PURE__ */ new Date())
|
|
1589
|
+
}),
|
|
1590
|
+
workflows: ({ params }) => {
|
|
1591
|
+
const project = projectOf(context, params.id);
|
|
1592
|
+
return project === void 0 ? fail(404, `No registered project \`${params.id}\`.`) : ok(projectWorkflows(project));
|
|
1593
|
+
},
|
|
1594
|
+
runs: ({ query }) => ok(context.store.runs({
|
|
1595
|
+
limit: query.limit ?? 20,
|
|
1596
|
+
...query.project === void 0 ? {} : { project: projectRoot(context, query.project) }
|
|
1597
|
+
}).map((run) => viewOf$2(context, run))),
|
|
1598
|
+
run: ({ params }) => {
|
|
1599
|
+
const found = runOf(context, params.id);
|
|
1600
|
+
return found.ok ? ok(viewOf$2(context, found.run)) : found.refusal;
|
|
1601
|
+
},
|
|
1602
|
+
tree: ({ params }) => {
|
|
1603
|
+
const found = runOf(context, params.id);
|
|
1604
|
+
if (!found.ok) return found.refusal;
|
|
1605
|
+
return ok(activeTree(context.store.events(found.run.id)));
|
|
1606
|
+
},
|
|
1607
|
+
graph: ({ params }) => {
|
|
1608
|
+
const found = runOf(context, params.id);
|
|
1609
|
+
if (!found.ok) return found.refusal;
|
|
1610
|
+
return ok(runGraph(context.store.events(found.run.id).map((s) => s.event), found.run.file));
|
|
1611
|
+
},
|
|
1612
|
+
events: ({ params, query }) => {
|
|
1613
|
+
const found = runOf(context, params.id);
|
|
1614
|
+
if (!found.ok) return found.refusal;
|
|
1615
|
+
const events = context.store.events(found.run.id, query.after ?? 0).filter((s) => query.node === void 0 || nodeOf(s.event) === query.node);
|
|
1616
|
+
const newest = query.last === void 0 ? events : events.slice(-query.last);
|
|
1617
|
+
return ok(query.limit === void 0 ? newest : newest.slice(0, query.limit));
|
|
1618
|
+
},
|
|
1619
|
+
decisions: ({ query }) => {
|
|
1620
|
+
if (query.run === void 0) return ok(pendingDecisions(context.store, query.project === void 0 ? {} : { project: projectRoot(context, query.project) }));
|
|
1621
|
+
const found = runOf(context, query.run);
|
|
1622
|
+
if (!found.ok) return found.refusal;
|
|
1623
|
+
return ok(pendingDecisions(context.store, { runId: found.run.id }));
|
|
1624
|
+
},
|
|
1625
|
+
decision: ({ params }) => {
|
|
1626
|
+
const found = context.store.decision(params.id);
|
|
1627
|
+
return found === void 0 ? fail(404, `No decision \`${params.id}\`.`) : ok(found);
|
|
1628
|
+
}
|
|
1629
|
+
});
|
|
1630
|
+
// ../server/src/mcp-tools.ts
|
|
1631
|
+
/**
|
|
1632
|
+
* @file The tools `ork mcp` serves Claude Code (RFC-012): what a project has,
|
|
1633
|
+
* the workflow format, runs, their events, what they spent, and what waits on
|
|
1634
|
+
* a person. Each answers from the same handler as `ork ui`'s API, and they
|
|
1635
|
+
* are built from its read routes and the builder's catalog alone, so none can
|
|
1636
|
+
* answer a decision, start, stop, pause or message a run.
|
|
1637
|
+
*/
|
|
1638
|
+
const NO_QUERY = {
|
|
1639
|
+
params: {},
|
|
1640
|
+
query: void 0,
|
|
1641
|
+
body: void 0
|
|
1642
|
+
};
|
|
1643
|
+
/** How many events `run_events` gives when it's asked for no range. */
|
|
1644
|
+
const RECENT_EVENTS = 30;
|
|
1645
|
+
/** Said with the open decisions: this server can't answer them. */
|
|
1646
|
+
const ANSWERED_BY_A_PERSON = "Only a person answers these: in `ork ui`, or with `ork decide` in their own terminal. Tell them what waits, and where.";
|
|
1647
|
+
const project = z.string().min(1).describe("A registered project's id or root");
|
|
1648
|
+
const run = z.string().min(1).describe("A run's id, or a unique start of it");
|
|
1649
|
+
const mcpTools = (context) => {
|
|
1650
|
+
const read = readRoutes(context);
|
|
1651
|
+
const { builderCatalog } = builderRoutes(context);
|
|
1652
|
+
return [
|
|
1653
|
+
tool("projects", "The projects registered with Ork on this machine.", {}, () => fromReply(read.projects(NO_QUERY))),
|
|
1654
|
+
tool("workflows", "A project's workflows: each one's name to start it by, its description and inputs, or why it doesn't parse.", { project }, (args) => fromReply(read.workflows({
|
|
1655
|
+
...NO_QUERY,
|
|
1656
|
+
params: { id: args.project }
|
|
1657
|
+
}))),
|
|
1658
|
+
tool("workflow_format", "The workflow file format's JSON Schema. With no node type: the workflow's own fields and the node types there are. With one: that node type's fields.", { nodeType: z.string().min(1).optional() }, (args) => formatPart(args.nodeType)),
|
|
1659
|
+
tool("agents", "The agents and skills a project's nodes can use, and where each comes from.", { project }, (args) => fromReply(builderCatalog({
|
|
1660
|
+
...NO_QUERY,
|
|
1661
|
+
params: { id: args.project }
|
|
1662
|
+
}))),
|
|
1663
|
+
tool("runs", "Runs, newest first, with their live status and cost. 10 unless a limit is given.", RunsQuerySchema.shape, (args) => fromReply(read.runs({
|
|
1664
|
+
...NO_QUERY,
|
|
1665
|
+
query: {
|
|
1666
|
+
limit: 10,
|
|
1667
|
+
...args
|
|
1668
|
+
}
|
|
1669
|
+
}))),
|
|
1670
|
+
tool("run", "One run: its status, what it waits on, its cost, and each node's state.", { run }, async (args) => {
|
|
1671
|
+
const view = await read.run({
|
|
1672
|
+
...NO_QUERY,
|
|
1673
|
+
params: { id: args.run }
|
|
1674
|
+
});
|
|
1675
|
+
if ("error" in view) return refused$1(view.error);
|
|
1676
|
+
const tree = read.tree({
|
|
1677
|
+
...NO_QUERY,
|
|
1678
|
+
params: { id: view.body.id }
|
|
1679
|
+
});
|
|
1680
|
+
return fromReply(tree, (nodes) => ({
|
|
1681
|
+
run: view.body,
|
|
1682
|
+
nodes
|
|
1683
|
+
}));
|
|
1684
|
+
}),
|
|
1685
|
+
tool("run_events", `A run's events, oldest first, long values cut. With no range, the newest ${String(RECENT_EVENTS)}.`, {
|
|
1686
|
+
run,
|
|
1687
|
+
...EventsQuerySchema.shape
|
|
1688
|
+
}, ({ run: id, ...query }) => {
|
|
1689
|
+
const range = query.after === void 0 && query.limit === void 0 && query.last === void 0 ? { last: RECENT_EVENTS } : {};
|
|
1690
|
+
return fromReply(read.events({
|
|
1691
|
+
...NO_QUERY,
|
|
1692
|
+
params: { id },
|
|
1693
|
+
query: {
|
|
1694
|
+
...range,
|
|
1695
|
+
...query
|
|
1696
|
+
}
|
|
1697
|
+
}));
|
|
1698
|
+
}),
|
|
1699
|
+
tool("usage", "What runs spent, by workflow and model, and how close the limits are. `since` and `until` are ISO times.", UsageQuerySchema.shape, (args) => fromReply(read.usage({
|
|
1700
|
+
...NO_QUERY,
|
|
1701
|
+
query: args
|
|
1702
|
+
}))),
|
|
1703
|
+
tool("decisions", "The decisions open in live runs, waiting on a person. This server can't answer them.", DecisionsQuerySchema.shape, (args) => fromReply(read.decisions({
|
|
1704
|
+
...NO_QUERY,
|
|
1705
|
+
query: args
|
|
1706
|
+
}), (open) => ({
|
|
1707
|
+
open,
|
|
1708
|
+
answer: ANSWERED_BY_A_PERSON
|
|
1709
|
+
})))
|
|
1710
|
+
];
|
|
1711
|
+
};
|
|
1712
|
+
const NOT_HERE = "This server was not started by `ork ui`.";
|
|
1713
|
+
/** Asks the registry when the daily check is due; never throws. */
|
|
1714
|
+
const checkIfDue = async (file, options) => {
|
|
1715
|
+
try {
|
|
1716
|
+
const now = /* @__PURE__ */ new Date();
|
|
1717
|
+
const cache = readUpdateCache(file);
|
|
1718
|
+
if (!checkDue(cache, now)) return false;
|
|
1719
|
+
writeUpdateCache(file, {
|
|
1720
|
+
...cache,
|
|
1721
|
+
checkedAt: now.toISOString()
|
|
1722
|
+
});
|
|
1723
|
+
const registry = registryOf(options.env ?? process.env);
|
|
1724
|
+
await refreshUpdateCache(file, options.name, now, {
|
|
1725
|
+
...registry === void 0 ? {} : { registry },
|
|
1726
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch }
|
|
1727
|
+
});
|
|
1728
|
+
return true;
|
|
1729
|
+
} catch {
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
const createUpdates = (input) => {
|
|
1734
|
+
const { version, options } = input;
|
|
1735
|
+
const file = updateCachePath(input.home);
|
|
1736
|
+
const env = options?.env ?? process.env;
|
|
1737
|
+
const switched = options === void 0 ? null : updateSwitchOff(env);
|
|
1738
|
+
const reason = options === void 0 ? NOT_HERE : switched === null ? null : `${switched} is set`;
|
|
1739
|
+
let record = null;
|
|
1740
|
+
let job = null;
|
|
1741
|
+
const read = () => {
|
|
1742
|
+
const at = Date.now();
|
|
1743
|
+
const every = options?.readEveryMs ?? 6e4;
|
|
1744
|
+
if (record === null || at - record.at >= every) record = {
|
|
1745
|
+
cache: readUpdateCache(file),
|
|
1746
|
+
at
|
|
1747
|
+
};
|
|
1748
|
+
return record.cache;
|
|
1749
|
+
};
|
|
1750
|
+
let timer;
|
|
1751
|
+
if (options !== void 0 && reason === null) {
|
|
1752
|
+
const due = () => {
|
|
1753
|
+
checkIfDue(file, options).then((asked) => {
|
|
1754
|
+
if (asked) record = null;
|
|
1755
|
+
return asked;
|
|
1756
|
+
});
|
|
1757
|
+
};
|
|
1758
|
+
due();
|
|
1759
|
+
timer = setInterval(due, options.checkEveryMs ?? 36e5);
|
|
1760
|
+
timer.unref();
|
|
1761
|
+
}
|
|
1762
|
+
return {
|
|
1763
|
+
info: () => {
|
|
1764
|
+
const cache = read();
|
|
1765
|
+
const newer = options === void 0 || reason !== null ? null : newerVersion(cache, version);
|
|
1766
|
+
const registry = registryOf(env);
|
|
1767
|
+
return {
|
|
1768
|
+
current: version,
|
|
1769
|
+
latest: cache?.latest ?? null,
|
|
1770
|
+
newer,
|
|
1771
|
+
checkedAt: cache?.checkedAt ?? null,
|
|
1772
|
+
releaseNotes: newer === null || options === void 0 ? null : releaseNotes(options.name, newer),
|
|
1773
|
+
check: reason === null ? { on: true } : {
|
|
1774
|
+
on: false,
|
|
1775
|
+
reason
|
|
1776
|
+
},
|
|
1777
|
+
registry: registry === void 0 ? null : registryShown(registry),
|
|
1778
|
+
job: job?.view() ?? null
|
|
1779
|
+
};
|
|
1780
|
+
},
|
|
1781
|
+
options,
|
|
1782
|
+
file,
|
|
1783
|
+
forget: () => {
|
|
1784
|
+
record = null;
|
|
1785
|
+
},
|
|
1786
|
+
job: () => job,
|
|
1787
|
+
setJob: (next) => {
|
|
1788
|
+
job = next;
|
|
1789
|
+
},
|
|
1790
|
+
close: () => {
|
|
1791
|
+
clearInterval(timer);
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
};
|
|
1795
|
+
// ../server/src/mcp.ts
|
|
1796
|
+
/**
|
|
1797
|
+
* @file What `ork mcp` opens (RFC-012): the store under a home, and the tools
|
|
1798
|
+
* on it, as one more client of the store beside the CLI and `ork ui`.
|
|
1799
|
+
* Starting a run is offered only where the caller allows it: not inside a
|
|
1800
|
+
* run, whose sessions mustn't start more.
|
|
1801
|
+
*/
|
|
1802
|
+
const openMcp = (options) => {
|
|
1803
|
+
const store = openStore(storePath(options.home));
|
|
1804
|
+
const context = {
|
|
1805
|
+
store,
|
|
1806
|
+
version: options.version,
|
|
1807
|
+
pid: process.pid,
|
|
1808
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1809
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
1810
|
+
...options.script === void 0 ? {} : { script: options.script },
|
|
1811
|
+
updates: createUpdates({ version: options.version })
|
|
1812
|
+
};
|
|
1813
|
+
return {
|
|
1814
|
+
tools: [...mcpTools(context), ...actingTools(context, { start: options.start })],
|
|
1815
|
+
fileOf: (key, workflow) => {
|
|
1816
|
+
const project = projectOf(context, key);
|
|
1817
|
+
if (project === void 0) return { error: `No registered project \`${key}\`.` };
|
|
1818
|
+
const file = workflowFile(project, workflow);
|
|
1819
|
+
return file === null ? { error: `No workflow \`${workflow}\` in \`${project.id}\`'s workflows or inside its root.` } : {
|
|
1820
|
+
project,
|
|
1821
|
+
file
|
|
1822
|
+
};
|
|
1823
|
+
},
|
|
1824
|
+
close: () => {
|
|
1825
|
+
store.close();
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
};
|
|
1829
|
+
// ../server/src/assets.ts
|
|
1830
|
+
/**
|
|
1831
|
+
* @file The built UI's files (`packages/ui/dist`, shipped as the CLI's
|
|
1832
|
+
* `dist/ui`): `/` is its `index.html`, and `/assets/…` are its hashed files,
|
|
1833
|
+
* cached for good. Only a file inside the folder, with a known type, is
|
|
1834
|
+
* served; anything else is none.
|
|
1835
|
+
*/
|
|
1836
|
+
/** The types the UI's build writes; anything else is not served. */
|
|
1837
|
+
const TYPES = {
|
|
1838
|
+
".html": "text/html; charset=utf-8",
|
|
1839
|
+
".js": "text/javascript; charset=utf-8",
|
|
1840
|
+
".css": "text/css; charset=utf-8",
|
|
1841
|
+
".svg": "image/svg+xml",
|
|
1842
|
+
".png": "image/png",
|
|
1843
|
+
".ico": "image/x-icon",
|
|
1844
|
+
".woff2": "font/woff2",
|
|
1845
|
+
".woff": "font/woff",
|
|
1846
|
+
".txt": "text/plain; charset=utf-8",
|
|
1847
|
+
".webmanifest": "application/manifest+json"
|
|
1848
|
+
};
|
|
1849
|
+
/** Whether `dir` holds a built UI. */
|
|
1850
|
+
const hasUi = (dir) => {
|
|
1851
|
+
try {
|
|
1852
|
+
return statSync(path.join(dir, "index.html")).isFile();
|
|
1853
|
+
} catch {
|
|
1854
|
+
return false;
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
/** The file `pathname` names in the built UI `dir`, or null. */
|
|
1858
|
+
const readAsset = (dir, pathname) => {
|
|
1859
|
+
const relative = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
1860
|
+
let decoded;
|
|
1861
|
+
try {
|
|
1862
|
+
decoded = decodeURIComponent(relative);
|
|
1863
|
+
} catch {
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
const root = path.resolve(dir);
|
|
1867
|
+
const file = path.resolve(root, decoded);
|
|
1868
|
+
const type = TYPES[path.extname(file)];
|
|
1869
|
+
if (type === void 0 || !file.startsWith(`${root}${path.sep}`)) return null;
|
|
1870
|
+
try {
|
|
1871
|
+
if (!statSync(file).isFile()) return null;
|
|
1872
|
+
return {
|
|
1873
|
+
body: readFileSync(file),
|
|
1874
|
+
type,
|
|
1875
|
+
immutable: decoded.startsWith("assets/")
|
|
1876
|
+
};
|
|
1877
|
+
} catch {
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
};
|
|
1881
|
+
// ../server/src/context-routes.ts
|
|
1882
|
+
/**
|
|
1883
|
+
* @file The context inspector's routes: what one node's session would load,
|
|
1884
|
+
* planned without writing anything. A run's node is planned from the run's
|
|
1885
|
+
* workflow file as it is now, naming each file behind it that changed after
|
|
1886
|
+
* the run started; a builder's node from the draft, else the file, so an edit
|
|
1887
|
+
* shows at once.
|
|
1888
|
+
*/
|
|
1889
|
+
/** Whether `file` was written after `since`, an ISO time. */
|
|
1890
|
+
const changedAfter = (file, since) => {
|
|
1891
|
+
try {
|
|
1892
|
+
return statSync(file).mtimeMs > Date.parse(since);
|
|
1893
|
+
} catch {
|
|
1894
|
+
return false;
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
/** A file as the user knows it: in the project, under `~`, or whole. */
|
|
1898
|
+
const shownAs = (file, root, home) => {
|
|
1899
|
+
const inside = (base) => {
|
|
1900
|
+
const relative = path.relative(base, file);
|
|
1901
|
+
return relative.startsWith("..") || path.isAbsolute(relative) ? void 0 : relative;
|
|
1902
|
+
};
|
|
1903
|
+
const inProject = inside(root);
|
|
1904
|
+
if (inProject !== void 0) return inProject;
|
|
1905
|
+
const inHome = inside(home);
|
|
1906
|
+
return inHome === void 0 ? file : `~/${inHome}`;
|
|
1907
|
+
};
|
|
1908
|
+
const answerOf = (result, changedSinceStart) => result.ok ? {
|
|
1909
|
+
planned: true,
|
|
1910
|
+
explanation: result.explanation,
|
|
1911
|
+
changedSinceStart
|
|
1912
|
+
} : {
|
|
1913
|
+
planned: false,
|
|
1914
|
+
why: result.error,
|
|
1915
|
+
changedSinceStart
|
|
1916
|
+
};
|
|
1917
|
+
const contextRoutes = (context) => {
|
|
1918
|
+
/** Where to plan: in `place`, when the project is registered. */
|
|
1919
|
+
const where = (place) => ({
|
|
1920
|
+
registered: context.store.projects(),
|
|
1921
|
+
...context.home === void 0 ? {} : { home: context.home },
|
|
1922
|
+
...place === void 0 ? {} : { place }
|
|
1923
|
+
});
|
|
1924
|
+
return {
|
|
1925
|
+
runContext: ({ params }) => {
|
|
1926
|
+
const found = runOf(context, params.id);
|
|
1927
|
+
if (!found.ok) return found.refusal;
|
|
1928
|
+
const { file, startedAt, project } = found.run;
|
|
1929
|
+
const home = context.home ?? homedir();
|
|
1930
|
+
const text = readText(file);
|
|
1931
|
+
if (text === void 0) return ok({
|
|
1932
|
+
planned: false,
|
|
1933
|
+
why: `The run's workflow file \`${file}\` is gone.`,
|
|
1934
|
+
changedSinceStart: [shownAs(file, project, home)]
|
|
1935
|
+
});
|
|
1936
|
+
const result = explainWorkflowText(text, file, params.node, where(projectOf(context, project)));
|
|
1937
|
+
const read = [file, ...result.ok ? result.sources : []];
|
|
1938
|
+
return ok(answerOf(result, [...new Set(read)].filter((each) => changedAfter(each, startedAt)).map((each) => shownAs(each, project, home))));
|
|
1939
|
+
},
|
|
1940
|
+
builderContext: ({ params }) => {
|
|
1941
|
+
const project = projectOf(context, params.id);
|
|
1942
|
+
if (project === void 0) return fail(404, `No registered project \`${params.id}\`.`);
|
|
1943
|
+
const problem = problemWith(project, params.name);
|
|
1944
|
+
if (problem !== void 0) return fail(problem.refused, problem.error);
|
|
1945
|
+
const file = fileOf(project, params.name);
|
|
1946
|
+
const text = context.store.draft(project.id, params.name)?.text ?? readText(file);
|
|
1947
|
+
if (text === void 0) {
|
|
1948
|
+
const missing = notFound(project, params.name);
|
|
1949
|
+
return fail(missing.refused, missing.error);
|
|
1950
|
+
}
|
|
1951
|
+
return ok(answerOf(explainWorkflowText(text, file, params.node, where(project)), []));
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
};
|
|
1955
|
+
// ../server/src/feedback-apply.ts
|
|
1956
|
+
/**
|
|
1957
|
+
* @file Applying an item from the Feedback panel (ADR-059): the same checks as
|
|
1958
|
+
* `ork feedback apply`, then that command started in the background with a
|
|
1959
|
+
* run id picked here, so the Monitor can follow the run and its gate's keep
|
|
1960
|
+
* or discard settles the item exactly as in a terminal.
|
|
1961
|
+
*/
|
|
1962
|
+
const BUILTIN = "ork:feedback-apply";
|
|
1963
|
+
/** Starts a run proposing a fix for `item`, or says why it can't. */
|
|
1964
|
+
const applyRoute = async (context, item, driver) => {
|
|
1965
|
+
const { store } = context;
|
|
1966
|
+
const allowed = feedbackActions(item, openFixRun(store, item)).apply;
|
|
1967
|
+
if (!allowed.ok) return fail(409, allowed.error);
|
|
1968
|
+
const found = placeOf$1(store, item, void 0, store.projects());
|
|
1969
|
+
if (!found.ok) return fail(409, found.error);
|
|
1970
|
+
const { place } = found;
|
|
1971
|
+
const problem = builtinProblem(place, BUILTIN);
|
|
1972
|
+
if (problem !== void 0) return fail(409, problem);
|
|
1973
|
+
const runId = newRunId(store);
|
|
1974
|
+
const detached = await detachRun({
|
|
1975
|
+
store,
|
|
1976
|
+
runId,
|
|
1977
|
+
args: [
|
|
1978
|
+
"feedback",
|
|
1979
|
+
"apply",
|
|
1980
|
+
item.id,
|
|
1981
|
+
"--run-id",
|
|
1982
|
+
runId,
|
|
1983
|
+
...driver === void 0 ? [] : ["--driver", driver]
|
|
1984
|
+
],
|
|
1985
|
+
cwd: place.root,
|
|
1986
|
+
runDir: runDirOf(place, runId),
|
|
1987
|
+
...context.script === void 0 ? {} : { script: context.script }
|
|
1988
|
+
});
|
|
1989
|
+
return detached.ok ? accepted({
|
|
1990
|
+
runId,
|
|
1991
|
+
root: place.root
|
|
1992
|
+
}) : fail(400, detached.error || "The run did not start.");
|
|
1993
|
+
};
|
|
1994
|
+
// ../server/src/feedback-detail.ts
|
|
1995
|
+
/**
|
|
1996
|
+
* @file An item as the Feedback panel shows it: its records, which actions it
|
|
1997
|
+
* allows (the rule `ork feedback` keeps too), where a lesson for each of its
|
|
1998
|
+
* workflows would go and how big that file is, the project a fix would be
|
|
1999
|
+
* proposed in, and the run proposing one now, if any.
|
|
2000
|
+
*/
|
|
2001
|
+
const detailOf = (store, item) => {
|
|
2002
|
+
const registered = store.projects();
|
|
2003
|
+
const fixRun = openFixRun(store, item);
|
|
2004
|
+
const actions = feedbackActions(item, fixRun);
|
|
2005
|
+
const found = placeOf$1(store, item, void 0, registered);
|
|
2006
|
+
return {
|
|
2007
|
+
item,
|
|
2008
|
+
records: store.feedbackRecords(item.id),
|
|
2009
|
+
actions,
|
|
2010
|
+
lessons: item.target === "ork" ? [] : item.workflows.map((w) => lessonPlaceOf(store, item, w, registered)),
|
|
2011
|
+
lessonCap: LESSON_CAP_TOKENS,
|
|
2012
|
+
project: found.ok && item.target !== "ork" ? {
|
|
2013
|
+
ok: true,
|
|
2014
|
+
root: found.place.root
|
|
2015
|
+
} : {
|
|
2016
|
+
ok: false,
|
|
2017
|
+
error: found.ok ? "It's about Ork itself." : found.error
|
|
2018
|
+
},
|
|
2019
|
+
...fixRun === void 0 ? {} : { fixRun }
|
|
2020
|
+
};
|
|
2021
|
+
};
|
|
2022
|
+
// ../server/src/feedback-publish.ts
|
|
2023
|
+
/**
|
|
2024
|
+
* @file Publishing an `ork` item from the Feedback panel (ADR-059): the preview
|
|
2025
|
+
* shows exactly what would be sent and where, with a digest of it; the
|
|
2026
|
+
* publish makes the preview again and sends it only when its digest is the
|
|
2027
|
+
* one the person saw, with their own `gh` login.
|
|
2028
|
+
*/
|
|
2029
|
+
const refused = (status, error) => ({
|
|
2030
|
+
ok: false,
|
|
2031
|
+
refusal: fail(status, error)
|
|
2032
|
+
});
|
|
2033
|
+
/** The preview of `item`'s post, and its digest, or why there's none. */
|
|
2034
|
+
const previewOf = async (context, item, hideNames) => {
|
|
2035
|
+
const shown = previewPost(context.store, item, {
|
|
2036
|
+
home: context.home ?? homedir(),
|
|
2037
|
+
registered: context.store.projects(),
|
|
2038
|
+
hideNames
|
|
2039
|
+
});
|
|
2040
|
+
if (!shown.ok) return refused(409, shown.error);
|
|
2041
|
+
const planned = await planPost(shown.post, context.gh ?? runGh);
|
|
2042
|
+
if (!planned.ok) return refused(400, planned.error);
|
|
2043
|
+
const digest = createHash("sha256").update(JSON.stringify([shown.post, planned.plan])).digest("hex");
|
|
2044
|
+
return {
|
|
2045
|
+
ok: true,
|
|
2046
|
+
preview: {
|
|
2047
|
+
post: shown.post,
|
|
2048
|
+
plan: planned.plan,
|
|
2049
|
+
repo: `${DISCUSSIONS.owner}/${DISCUSSIONS.name}`,
|
|
2050
|
+
digest
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
};
|
|
2054
|
+
/** Sends `item`'s post when it's still the one previewed as `digest`. */
|
|
2055
|
+
const sendPublish = async (context, item, ask) => {
|
|
2056
|
+
const previewed = await previewOf(context, item, ask.hideNames);
|
|
2057
|
+
if (!previewed.ok) return previewed;
|
|
2058
|
+
const { post, plan, digest } = previewed.preview;
|
|
2059
|
+
if (digest !== ask.digest) return refused(409, "What would be sent changed after the preview, so nothing was sent: look at the preview again.");
|
|
2060
|
+
const sent = await sendPost(post, plan, context.gh ?? runGh);
|
|
2061
|
+
if (!sent.ok) return refused(400, sent.error);
|
|
2062
|
+
context.store.setFeedbackStatus(item.id, "published");
|
|
2063
|
+
return {
|
|
2064
|
+
ok: true,
|
|
2065
|
+
url: sent.url,
|
|
2066
|
+
kind: sent.kind
|
|
2067
|
+
};
|
|
2068
|
+
};
|
|
2069
|
+
// ../server/src/feedback-routes.ts
|
|
2070
|
+
/**
|
|
2071
|
+
* @file The feedback loop's routes (specs/feedback.md), through the same core
|
|
2072
|
+
* functions as `ork feedback`: the items and an item's records; dismiss and
|
|
2073
|
+
* restore; accept, maybe as a lesson; apply, which starts `ork feedback
|
|
2074
|
+
* apply` in the background so its keep-or-discard ends the same way; publish
|
|
2075
|
+
* after a preview, sending only what the preview showed; and a person's note
|
|
2076
|
+
* on a run, or one node of it.
|
|
2077
|
+
*/
|
|
2078
|
+
/** The item `id` names (its fingerprint, or a group's member), or a 404. */
|
|
2079
|
+
const itemOf = (store, id) => {
|
|
2080
|
+
const [item, other] = store.findFeedback(id);
|
|
2081
|
+
return item === void 0 || other !== void 0 ? {
|
|
2082
|
+
ok: false,
|
|
2083
|
+
refusal: fail(404, `No feedback item ${id}.`)
|
|
2084
|
+
} : {
|
|
2085
|
+
ok: true,
|
|
2086
|
+
item
|
|
2087
|
+
};
|
|
2088
|
+
};
|
|
2089
|
+
/** The item as it is now, after a write. */
|
|
2090
|
+
const fresh = (store, item) => store.findFeedback(item.id)[0] ?? item;
|
|
2091
|
+
const feedbackRoutes = (context) => {
|
|
2092
|
+
const { store } = context;
|
|
2093
|
+
return {
|
|
2094
|
+
note: ({ params, body }) => {
|
|
2095
|
+
const found = runOf(context, params.id);
|
|
2096
|
+
if (!found.ok) return found.refusal;
|
|
2097
|
+
const noted = recordNote(store, found.run, body);
|
|
2098
|
+
return noted.ok ? ok({ item: noted.item }) : fail(400, noted.error);
|
|
2099
|
+
},
|
|
2100
|
+
feedbackItems: () => ok(store.feedbackItems({ all: true })),
|
|
2101
|
+
feedbackItem: ({ params }) => {
|
|
2102
|
+
const found = itemOf(store, params.id);
|
|
2103
|
+
if (!found.ok) return found.refusal;
|
|
2104
|
+
return ok(detailOf(store, found.item));
|
|
2105
|
+
},
|
|
2106
|
+
feedbackStatus: ({ params, body }) => {
|
|
2107
|
+
const found = itemOf(store, params.id);
|
|
2108
|
+
if (!found.ok) return found.refusal;
|
|
2109
|
+
const { item } = found;
|
|
2110
|
+
if (body.status === "new" && item.status !== "dismissed") return fail(409, `${item.id.slice(0, 12)} isn't dismissed, so there's nothing to restore.`);
|
|
2111
|
+
store.setFeedbackStatus(item.id, body.status);
|
|
2112
|
+
return ok(fresh(store, item));
|
|
2113
|
+
},
|
|
2114
|
+
accept: ({ params, body }) => {
|
|
2115
|
+
const found = itemOf(store, params.id);
|
|
2116
|
+
if (!found.ok) return found.refusal;
|
|
2117
|
+
const accepted = acceptItem(store, found.item, body, context.store.projects());
|
|
2118
|
+
if (!accepted.ok) return fail(409, accepted.error);
|
|
2119
|
+
return ok({
|
|
2120
|
+
item: fresh(store, found.item),
|
|
2121
|
+
...accepted.lesson === void 0 ? {} : { lesson: accepted.lesson }
|
|
2122
|
+
});
|
|
2123
|
+
},
|
|
2124
|
+
apply: ({ params, body }) => {
|
|
2125
|
+
const found = itemOf(store, params.id);
|
|
2126
|
+
if (!found.ok) return found.refusal;
|
|
2127
|
+
return applyRoute(context, found.item, body.driver);
|
|
2128
|
+
},
|
|
2129
|
+
publishPreview: async ({ params, body }) => {
|
|
2130
|
+
const found = itemOf(store, params.id);
|
|
2131
|
+
if (!found.ok) return found.refusal;
|
|
2132
|
+
const previewed = await previewOf(context, found.item, body.hideNames);
|
|
2133
|
+
return previewed.ok ? ok(previewed.preview) : previewed.refusal;
|
|
2134
|
+
},
|
|
2135
|
+
publish: async ({ params, body }) => {
|
|
2136
|
+
const found = itemOf(store, params.id);
|
|
2137
|
+
if (!found.ok) return found.refusal;
|
|
2138
|
+
const sent = await sendPublish(context, found.item, body);
|
|
2139
|
+
return sent.ok ? ok({
|
|
2140
|
+
item: fresh(store, found.item),
|
|
2141
|
+
url: sent.url,
|
|
2142
|
+
kind: sent.kind
|
|
2143
|
+
}) : sent.refusal;
|
|
2144
|
+
},
|
|
2145
|
+
split: ({ params }) => {
|
|
2146
|
+
const found = itemOf(store, params.id);
|
|
2147
|
+
if (!found.ok) return found.refusal;
|
|
2148
|
+
if (found.item.merged.length === 0) return fail(409, `Nothing was merged into ${found.item.id.slice(0, 12)}, so there's nothing to split.`);
|
|
2149
|
+
return ok({ moved: store.splitFeedback(found.item.id) });
|
|
2150
|
+
}
|
|
2151
|
+
};
|
|
2152
|
+
};
|
|
2153
|
+
const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)|\u001B[@-Z\\-_]/gu;
|
|
2154
|
+
/** A line as a terminal would end up showing it. */
|
|
2155
|
+
const plainLine = (raw) => (raw.replaceAll(ANSI, "").split("\r").findLast((part) => part !== "") ?? "").replaceAll(/\s+$/gu, "");
|
|
2156
|
+
const startJob = (latest, argv, install, onChange) => {
|
|
2157
|
+
const job = {
|
|
2158
|
+
id: randomUUID(),
|
|
2159
|
+
state: "running",
|
|
2160
|
+
latest,
|
|
2161
|
+
command: [...argv],
|
|
2162
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2163
|
+
};
|
|
2164
|
+
const lines = [];
|
|
2165
|
+
const partial = {
|
|
2166
|
+
stdout: "",
|
|
2167
|
+
stderr: ""
|
|
2168
|
+
};
|
|
2169
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2170
|
+
let seq = 0;
|
|
2171
|
+
let ended = null;
|
|
2172
|
+
const tell = (event) => {
|
|
2173
|
+
for (const listener of listeners) listener(event);
|
|
2174
|
+
};
|
|
2175
|
+
const keep = (stream, raw) => {
|
|
2176
|
+
seq += 1;
|
|
2177
|
+
const line = {
|
|
2178
|
+
seq,
|
|
2179
|
+
stream,
|
|
2180
|
+
text: plainLine(raw)
|
|
2181
|
+
};
|
|
2182
|
+
lines.push(line);
|
|
2183
|
+
if (lines.length > 2e3) lines.shift();
|
|
2184
|
+
tell({
|
|
2185
|
+
kind: "output",
|
|
2186
|
+
line
|
|
2187
|
+
});
|
|
2188
|
+
};
|
|
2189
|
+
const output = (stream, text) => {
|
|
2190
|
+
const parts = `${partial[stream]}${text}`.split("\n");
|
|
2191
|
+
partial[stream] = parts.pop() ?? "";
|
|
2192
|
+
for (const part of parts) keep(stream, part);
|
|
2193
|
+
};
|
|
2194
|
+
const finished = install(argv, output).catch((error) => {
|
|
2195
|
+
keep("stderr", error instanceof Error ? error.message : String(error));
|
|
2196
|
+
return 1;
|
|
2197
|
+
});
|
|
2198
|
+
const finish = async () => {
|
|
2199
|
+
const code = await finished;
|
|
2200
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
2201
|
+
if (partial[stream] !== "") keep(stream, partial[stream]);
|
|
2202
|
+
partial[stream] = "";
|
|
2203
|
+
}
|
|
2204
|
+
const state = code === 0 ? "succeeded" : "failed";
|
|
2205
|
+
Object.assign(job, {
|
|
2206
|
+
state,
|
|
2207
|
+
code,
|
|
2208
|
+
endedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2209
|
+
});
|
|
2210
|
+
ended = {
|
|
2211
|
+
state,
|
|
2212
|
+
code,
|
|
2213
|
+
version: latest
|
|
2214
|
+
};
|
|
2215
|
+
onChange();
|
|
2216
|
+
tell({
|
|
2217
|
+
kind: "end",
|
|
2218
|
+
end: ended
|
|
2219
|
+
});
|
|
2220
|
+
};
|
|
2221
|
+
return {
|
|
2222
|
+
view: () => ({
|
|
2223
|
+
...job,
|
|
2224
|
+
command: [...job.command]
|
|
2225
|
+
}),
|
|
2226
|
+
linesAfter: (after) => ({
|
|
2227
|
+
lines: lines.filter((line) => line.seq > after),
|
|
2228
|
+
dropped: (lines[0]?.seq ?? 1) > after + 1
|
|
2229
|
+
}),
|
|
2230
|
+
end: () => ended,
|
|
2231
|
+
done: finish(),
|
|
2232
|
+
listen: (listener) => {
|
|
2233
|
+
listeners.add(listener);
|
|
2234
|
+
return () => {
|
|
2235
|
+
listeners.delete(listener);
|
|
2236
|
+
};
|
|
2237
|
+
}
|
|
2238
|
+
};
|
|
2239
|
+
};
|
|
2240
|
+
// ../server/src/upgrade-plan.ts
|
|
2241
|
+
/**
|
|
2242
|
+
* @file The upgrade `ork ui` offers, which is `ork upgrade`'s
|
|
2243
|
+
* (specs/cli/updates.md): the plan asks the registry now and records the
|
|
2244
|
+
* answer as `ork upgrade` does, then gives core's plan with an id; running it
|
|
2245
|
+
* rebuilds the plan and runs the server's own command only when the id the
|
|
2246
|
+
* person read still matches, so the page never sends a command.
|
|
2247
|
+
*/
|
|
2248
|
+
/** How long the plan waits on the registry, as `ork upgrade`. */
|
|
2249
|
+
const ASK_TIMEOUT_MS = 15e3;
|
|
2250
|
+
const NO_UPGRADE = "This server can't upgrade Ork; run `ork upgrade` in a terminal.";
|
|
2251
|
+
const BUSY = "An upgrade is already running.";
|
|
2252
|
+
const UNREACHABLE = "Could not reach the npm registry to look for a new version. Nothing was run.";
|
|
2253
|
+
const ADVICE = "Ork runs only a package manager's own command from here; this one is yours to run.";
|
|
2254
|
+
/** Records what the registry said, as `ork upgrade` does. */
|
|
2255
|
+
const remember = (file, latest, notified) => {
|
|
2256
|
+
try {
|
|
2257
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2258
|
+
writeUpdateCache(file, {
|
|
2259
|
+
...readUpdateCache(file),
|
|
2260
|
+
checkedAt: now,
|
|
2261
|
+
latest,
|
|
2262
|
+
...notified ? { notified: {
|
|
2263
|
+
version: latest,
|
|
2264
|
+
at: now
|
|
2265
|
+
} } : {}
|
|
2266
|
+
});
|
|
2267
|
+
} catch {}
|
|
2268
|
+
};
|
|
2269
|
+
const idOf = (parts) => createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 16);
|
|
2270
|
+
/** Asks the registry now and says what an upgrade would do. */
|
|
2271
|
+
const askPlan = async (context) => {
|
|
2272
|
+
const { updates, version: current } = context;
|
|
2273
|
+
const { options } = updates;
|
|
2274
|
+
if (options === void 0) return null;
|
|
2275
|
+
const registry = registryOf(options.env ?? process.env);
|
|
2276
|
+
const latest = await fetchLatest(options.name, {
|
|
2277
|
+
...registry === void 0 ? {} : { registry },
|
|
2278
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
2279
|
+
timeoutMs: ASK_TIMEOUT_MS
|
|
2280
|
+
});
|
|
2281
|
+
const script = options.script ?? runningScript();
|
|
2282
|
+
const plan = planUpgrade({
|
|
2283
|
+
name: options.name,
|
|
2284
|
+
current,
|
|
2285
|
+
latest,
|
|
2286
|
+
script
|
|
2287
|
+
});
|
|
2288
|
+
if (plan.kind === "unreachable") return plan;
|
|
2289
|
+
if (latest !== null) remember(updates.file, latest, plan.kind !== "latest");
|
|
2290
|
+
updates.forget();
|
|
2291
|
+
if (plan.kind === "latest") return {
|
|
2292
|
+
kind: "latest",
|
|
2293
|
+
current
|
|
2294
|
+
};
|
|
2295
|
+
const shared = {
|
|
2296
|
+
current,
|
|
2297
|
+
latest: plan.latest,
|
|
2298
|
+
releaseNotes: releaseNotes(options.name, plan.latest),
|
|
2299
|
+
running: runningRunIds(context.store)
|
|
2300
|
+
};
|
|
2301
|
+
if (plan.kind === "command") {
|
|
2302
|
+
const command = [...plan.argv];
|
|
2303
|
+
const { id, label } = plan.installer;
|
|
2304
|
+
return {
|
|
2305
|
+
kind: "command",
|
|
2306
|
+
planId: idOf([
|
|
2307
|
+
plan.kind,
|
|
2308
|
+
plan.latest,
|
|
2309
|
+
id,
|
|
2310
|
+
command
|
|
2311
|
+
]),
|
|
2312
|
+
...shared,
|
|
2313
|
+
installer: {
|
|
2314
|
+
id,
|
|
2315
|
+
label
|
|
2316
|
+
},
|
|
2317
|
+
command
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
const installer = detectInstaller(script);
|
|
2321
|
+
return {
|
|
2322
|
+
kind: "advice",
|
|
2323
|
+
planId: idOf([
|
|
2324
|
+
plan.kind,
|
|
2325
|
+
plan.latest,
|
|
2326
|
+
installer?.id,
|
|
2327
|
+
plan.text
|
|
2328
|
+
]),
|
|
2329
|
+
...shared,
|
|
2330
|
+
installer: installer === void 0 ? null : {
|
|
2331
|
+
id: installer.id,
|
|
2332
|
+
label: installer.label
|
|
2333
|
+
},
|
|
2334
|
+
text: plan.text,
|
|
2335
|
+
words: plan.words,
|
|
2336
|
+
commands: [...plan.commands]
|
|
2337
|
+
};
|
|
2338
|
+
};
|
|
2339
|
+
const changed = (plan) => {
|
|
2340
|
+
return `The plan changed since you read it: ${plan.kind === "latest" ? `Ork ${plan.current} is the latest version now` : `Ork ${plan.latest} is out now`}. Read it again before upgrading.`;
|
|
2341
|
+
};
|
|
2342
|
+
const running = (context) => context.updates.job()?.view().state === "running";
|
|
2343
|
+
/** Runs the plan the person read, when it is still the plan. */
|
|
2344
|
+
const startUpgrade = async (context, planId) => {
|
|
2345
|
+
const { updates } = context;
|
|
2346
|
+
const { options } = updates;
|
|
2347
|
+
if (options === void 0) return fail(400, NO_UPGRADE);
|
|
2348
|
+
if (running(context)) return fail(409, BUSY);
|
|
2349
|
+
const plan = await askPlan(context);
|
|
2350
|
+
if (running(context)) return fail(409, BUSY);
|
|
2351
|
+
if (plan === null || plan.kind === "unreachable") return fail(400, UNREACHABLE);
|
|
2352
|
+
if (plan.kind === "latest" || plan.planId !== planId) return fail(409, changed(plan));
|
|
2353
|
+
if (plan.kind === "advice") return fail(400, ADVICE);
|
|
2354
|
+
const job = startJob(plan.latest, plan.command, options.install ?? runInstall, updates.forget);
|
|
2355
|
+
updates.setJob(job);
|
|
2356
|
+
return accepted({ job: job.view() });
|
|
2357
|
+
};
|
|
2358
|
+
const CANNOT_RESTART = "This server can't restart itself; stop it and run `ork ui`.";
|
|
2359
|
+
const RESTART_BUSY = "An upgrade is running; restart `ork ui` once it has ended.";
|
|
2360
|
+
const updateRoutes = (context, port) => ({
|
|
2361
|
+
version: () => ok(context.updates.info()),
|
|
2362
|
+
upgradePlan: async () => {
|
|
2363
|
+
const plan = await askPlan(context);
|
|
2364
|
+
return plan === null ? fail(400, NO_UPGRADE) : ok(plan);
|
|
2365
|
+
},
|
|
2366
|
+
upgrade: ({ body }) => startUpgrade(context, body.planId),
|
|
2367
|
+
restart: () => {
|
|
2368
|
+
const { restart } = context;
|
|
2369
|
+
if (restart === void 0) return fail(400, CANNOT_RESTART);
|
|
2370
|
+
if (context.updates.job()?.view().state === "running") return fail(409, RESTART_BUSY);
|
|
2371
|
+
setTimeout(restart, 100);
|
|
2372
|
+
return accepted({
|
|
2373
|
+
port: port(),
|
|
2374
|
+
pid: context.pid
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
// ../server/src/worktrees-read.ts
|
|
2379
|
+
/**
|
|
2380
|
+
* @file What the Worktrees panel reads, through the same core functions as `ork
|
|
2381
|
+
* worktrees`: the projects Ork knows (registered, or a stored run's), each
|
|
2382
|
+
* one's items with core's words and what Remove… allows, and the plan Clean
|
|
2383
|
+
* merged… would carry out, with a digest of it. A project git can't read
|
|
2384
|
+
* comes back with its error, never as a failed request.
|
|
2385
|
+
*/
|
|
2386
|
+
/** Every project Ork knows, by name: registered ones, then runs' others. */
|
|
2387
|
+
const knownProjects = (context) => {
|
|
2388
|
+
const names = new Map(context.store.projects().map((p) => [p.root, p.id]));
|
|
2389
|
+
for (const run of context.store.runs()) if (!names.has(run.project)) names.set(run.project, path.basename(run.project) || run.project);
|
|
2390
|
+
return [...names].map(([root, name]) => ({
|
|
2391
|
+
root,
|
|
2392
|
+
name
|
|
2393
|
+
})).toSorted((a, b) => a.name.localeCompare(b.name));
|
|
2394
|
+
};
|
|
2395
|
+
/** The project `root` names, or all of them; only roots Ork knows. */
|
|
2396
|
+
const projectsFor = (context, root) => {
|
|
2397
|
+
const known = knownProjects(context);
|
|
2398
|
+
if (root === void 0) return {
|
|
2399
|
+
ok: true,
|
|
2400
|
+
projects: known
|
|
2401
|
+
};
|
|
2402
|
+
const found = known.filter((p) => p.root === root);
|
|
2403
|
+
return found.length === 0 ? {
|
|
2404
|
+
ok: false,
|
|
2405
|
+
refusal: fail(404, `Ork doesn't know a project at ${root}.`)
|
|
2406
|
+
} : {
|
|
2407
|
+
ok: true,
|
|
2408
|
+
projects: found
|
|
2409
|
+
};
|
|
2410
|
+
};
|
|
2411
|
+
const failed = (known, error, fix) => ({
|
|
2412
|
+
...known,
|
|
2413
|
+
head: null,
|
|
2414
|
+
error,
|
|
2415
|
+
...fix === void 0 ? {} : { fix }
|
|
2416
|
+
});
|
|
2417
|
+
/** A project's items and runs still going, or why git couldn't read them. */
|
|
2418
|
+
const readProject = async (context, known) => {
|
|
2419
|
+
if (!existsSync(known.root)) {
|
|
2420
|
+
const registered = context.store.projects().find((p) => p.root === known.root);
|
|
2421
|
+
return failed(known, `its folder ${known.root} is gone.`, registered === void 0 ? void 0 : `ork projects move ${registered.id} <new path>`);
|
|
2422
|
+
}
|
|
2423
|
+
const head = await projectHead(known.root);
|
|
2424
|
+
if (!head.ok) return failed(known, "Not in a git repository with a commit.");
|
|
2425
|
+
try {
|
|
2426
|
+
const worktrees = await readWorktrees(context.store, known.root);
|
|
2427
|
+
return {
|
|
2428
|
+
...known,
|
|
2429
|
+
head: head.branch,
|
|
2430
|
+
worktrees
|
|
2431
|
+
};
|
|
2432
|
+
} catch (error) {
|
|
2433
|
+
return failed(known, error instanceof Error ? error.message : String(error));
|
|
2434
|
+
}
|
|
2435
|
+
};
|
|
2436
|
+
const runKnown = (context, id) => context.store.lookup(id).some((run) => run.id === id);
|
|
2437
|
+
const rowOf = (context, item, live) => {
|
|
2438
|
+
const going = live.has(item.runId);
|
|
2439
|
+
const refusal = removeRefusal(item, going);
|
|
2440
|
+
const keeps = item.merged ? cleanStep(item, {
|
|
2441
|
+
branches: false,
|
|
2442
|
+
force: false,
|
|
2443
|
+
live
|
|
2444
|
+
}).skip : void 0;
|
|
2445
|
+
return {
|
|
2446
|
+
...item,
|
|
2447
|
+
key: keyOf(item),
|
|
2448
|
+
label: labelOf(item),
|
|
2449
|
+
state: mergeStateOf(item),
|
|
2450
|
+
parts: stateParts(item),
|
|
2451
|
+
run: {
|
|
2452
|
+
known: runKnown(context, item.runId),
|
|
2453
|
+
live: going
|
|
2454
|
+
},
|
|
2455
|
+
...keeps === void 0 ? {} : { cleanKeeps: keeps },
|
|
2456
|
+
remove: {
|
|
2457
|
+
label: item.path === null ? "Delete branch…" : "Remove…",
|
|
2458
|
+
...refusal === void 0 ? {} : { refusal }
|
|
2459
|
+
}
|
|
2460
|
+
};
|
|
2461
|
+
};
|
|
2462
|
+
/** A read project as the list shows it. */
|
|
2463
|
+
const listed = (context, read) => {
|
|
2464
|
+
const { worktrees, ...rest } = read;
|
|
2465
|
+
const live = worktrees?.live ?? /* @__PURE__ */ new Set();
|
|
2466
|
+
return {
|
|
2467
|
+
...rest,
|
|
2468
|
+
items: (worktrees?.items ?? []).map((item) => rowOf(context, item, live))
|
|
2469
|
+
};
|
|
2470
|
+
};
|
|
2471
|
+
/** Clean merged's plan for a read project: never forced. */
|
|
2472
|
+
const cleanStepsOf = (read, branches) => read.worktrees === void 0 ? [] : planClean(read.worktrees.items, { merged: true }, {
|
|
2473
|
+
branches,
|
|
2474
|
+
force: false,
|
|
2475
|
+
live: read.worktrees.live
|
|
2476
|
+
});
|
|
2477
|
+
/** A read project's clean plan as the dialog shows it. */
|
|
2478
|
+
const cleanPlanOf = (read, steps) => ({
|
|
2479
|
+
root: read.root,
|
|
2480
|
+
name: read.name,
|
|
2481
|
+
head: read.head,
|
|
2482
|
+
...read.error === void 0 ? {} : { error: read.error },
|
|
2483
|
+
steps: steps.map((step) => ({
|
|
2484
|
+
key: keyOf(step.item),
|
|
2485
|
+
line: planLine(step),
|
|
2486
|
+
removeWorktree: step.removeWorktree,
|
|
2487
|
+
deleteBranch: step.deleteBranch,
|
|
2488
|
+
prunes: stepPrunes(step),
|
|
2489
|
+
...step.skip === void 0 ? {} : { skip: step.skip }
|
|
2490
|
+
}))
|
|
2491
|
+
});
|
|
2492
|
+
/** What a person was shown, as a digest an act hands back. */
|
|
2493
|
+
const digestOf = (shown) => createHash("sha256").update(JSON.stringify(shown)).digest("hex");
|
|
2494
|
+
// ../server/src/worktrees-remove.ts
|
|
2495
|
+
/**
|
|
2496
|
+
* @file Remove… for one item, the twin of `ork worktrees remove`: the plan by
|
|
2497
|
+
* core's rule with what it would lose, as git prints it, and a digest of
|
|
2498
|
+
* both; then the removal, which plans again and goes ahead only when the
|
|
2499
|
+
* digest is the one the person saw, and forces only a plan that needed it.
|
|
2500
|
+
* What to remove is resolved from a fresh listing, never a path sent in.
|
|
2501
|
+
*/
|
|
2502
|
+
/** How many of git's lines a well shows; the rest are counted. */
|
|
2503
|
+
const SHOWN_LINES = 20;
|
|
2504
|
+
const firstOf = (all) => all.slice(0, SHOWN_LINES);
|
|
2505
|
+
const moreOf = (all) => Math.max(0, all.length - SHOWN_LINES);
|
|
2506
|
+
/** One item's remove plan, as the dialog shows it, or why there's none. */
|
|
2507
|
+
const removePlanOf = async (context, ask) => {
|
|
2508
|
+
const found = projectsFor(context, ask.project);
|
|
2509
|
+
if (!found.ok) return found;
|
|
2510
|
+
const [known] = found.projects;
|
|
2511
|
+
if (known === void 0) return {
|
|
2512
|
+
ok: false,
|
|
2513
|
+
refusal: fail(404, "No project.")
|
|
2514
|
+
};
|
|
2515
|
+
const read = await readProject(context, known);
|
|
2516
|
+
if (read.worktrees === void 0) {
|
|
2517
|
+
const why = `Could not read ${known.name}'s worktrees: ${read.error ?? ""}`;
|
|
2518
|
+
return {
|
|
2519
|
+
ok: false,
|
|
2520
|
+
refusal: fail(400, why)
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
const planned = planRemove(read.worktrees, ask.key, ask.deleteBranch);
|
|
2524
|
+
if (!planned.ok) return {
|
|
2525
|
+
ok: false,
|
|
2526
|
+
refusal: fail(planned.gone ? 404 : 400, planned.error)
|
|
2527
|
+
};
|
|
2528
|
+
const { plan } = planned;
|
|
2529
|
+
const lost = await lostWork(known.root, plan);
|
|
2530
|
+
const losing = {
|
|
2531
|
+
changes: firstOf(lost.changes),
|
|
2532
|
+
commits: firstOf(lost.commits),
|
|
2533
|
+
commitsOn: lost.commitsOn,
|
|
2534
|
+
more: {
|
|
2535
|
+
changes: moreOf(lost.changes),
|
|
2536
|
+
commits: moreOf(lost.commits)
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
const { step } = plan;
|
|
2540
|
+
return {
|
|
2541
|
+
ok: true,
|
|
2542
|
+
known,
|
|
2543
|
+
plan,
|
|
2544
|
+
answer: {
|
|
2545
|
+
parts: stateParts(step.item, read.head ?? void 0),
|
|
2546
|
+
merged: step.item.merged,
|
|
2547
|
+
ahead: step.item.ahead,
|
|
2548
|
+
line: planLine(step),
|
|
2549
|
+
removeWorktree: step.removeWorktree,
|
|
2550
|
+
deleteBranch: step.deleteBranch,
|
|
2551
|
+
prunes: stepPrunes(step) && !step.removeWorktree,
|
|
2552
|
+
force: plan.force,
|
|
2553
|
+
losing,
|
|
2554
|
+
digest: digestOf({
|
|
2555
|
+
step,
|
|
2556
|
+
force: plan.force,
|
|
2557
|
+
lost
|
|
2558
|
+
})
|
|
2559
|
+
}
|
|
2560
|
+
};
|
|
2561
|
+
};
|
|
2562
|
+
const CHANGED = "It changed since you read it. Read it again before removing.";
|
|
2563
|
+
/** Removes the item when its plan is still the one shown as `digest`. */
|
|
2564
|
+
const removeIt = async (context, ask, busy) => {
|
|
2565
|
+
if (busy.has(ask.project)) {
|
|
2566
|
+
const name = projectsFor(context, ask.project);
|
|
2567
|
+
const called = name.ok ? name.projects[0]?.name ?? ask.project : ask.project;
|
|
2568
|
+
return {
|
|
2569
|
+
ok: false,
|
|
2570
|
+
refusal: fail(409, `A remove or clean is already running in ${called}.`)
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
busy.add(ask.project);
|
|
2574
|
+
try {
|
|
2575
|
+
const planned = await removePlanOf(context, ask);
|
|
2576
|
+
if (!planned.ok) return planned;
|
|
2577
|
+
if (planned.answer.digest !== ask.digest) return {
|
|
2578
|
+
ok: false,
|
|
2579
|
+
refusal: fail(409, CHANGED)
|
|
2580
|
+
};
|
|
2581
|
+
if (planned.plan.force && !ask.force) return {
|
|
2582
|
+
ok: false,
|
|
2583
|
+
refusal: fail(400, "That loses work: confirm what's lost first.")
|
|
2584
|
+
};
|
|
2585
|
+
const done = [];
|
|
2586
|
+
try {
|
|
2587
|
+
await applyRemove(planned.known.root, planned.plan, done);
|
|
2588
|
+
} catch (error) {
|
|
2589
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
2590
|
+
return {
|
|
2591
|
+
ok: false,
|
|
2592
|
+
refusal: fail(400, why)
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
return {
|
|
2596
|
+
ok: true,
|
|
2597
|
+
done
|
|
2598
|
+
};
|
|
2599
|
+
} finally {
|
|
2600
|
+
busy.delete(ask.project);
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
// ../server/src/worktrees-routes.ts
|
|
2604
|
+
/**
|
|
2605
|
+
* @file The Worktrees panel's routes (specs/worktrees.md), through the same
|
|
2606
|
+
* core functions as `ork worktrees`: the list for every project Ork knows
|
|
2607
|
+
* (read in parallel, a project git can't read carrying its error), Remove…
|
|
2608
|
+
* (worktrees-remove.ts), and Clean merged…, which only ever cleans what's
|
|
2609
|
+
* merged, never forced, and goes ahead only when its plan's digest is the one
|
|
2610
|
+
* the person saw. A project takes one write at a time.
|
|
2611
|
+
*/
|
|
2612
|
+
const PLAN_CHANGED = "The plan changed since you read it. Read it again before cleaning.";
|
|
2613
|
+
/** Carries out one project's part of a clean; what it did, and where it stopped. */
|
|
2614
|
+
const cleanOne = async (read, branches) => {
|
|
2615
|
+
const done = [];
|
|
2616
|
+
const acting = cleanStepsOf(read, branches).filter((step) => stepActs(step));
|
|
2617
|
+
try {
|
|
2618
|
+
if (acting.length > 0) await applyClean(read.root, acting, false, done);
|
|
2619
|
+
return {
|
|
2620
|
+
root: read.root,
|
|
2621
|
+
name: read.name,
|
|
2622
|
+
done
|
|
2623
|
+
};
|
|
2624
|
+
} catch (error) {
|
|
2625
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
2626
|
+
return {
|
|
2627
|
+
root: read.root,
|
|
2628
|
+
name: read.name,
|
|
2629
|
+
done,
|
|
2630
|
+
error: why
|
|
2631
|
+
};
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
const worktreesRoutes = (context) => {
|
|
2635
|
+
/** The projects a remove or clean is writing to now. */
|
|
2636
|
+
const busy = /* @__PURE__ */ new Set();
|
|
2637
|
+
const readAll = async (project) => {
|
|
2638
|
+
const found = projectsFor(context, project);
|
|
2639
|
+
if (!found.ok) return found;
|
|
2640
|
+
return {
|
|
2641
|
+
ok: true,
|
|
2642
|
+
reads: await Promise.all(found.projects.map((known) => readProject(context, known)))
|
|
2643
|
+
};
|
|
2644
|
+
};
|
|
2645
|
+
const cleanPlan = async (body) => {
|
|
2646
|
+
const all = await readAll(body.project);
|
|
2647
|
+
if (!all.ok) return all;
|
|
2648
|
+
const projects = all.reads.map((read) => cleanPlanOf(read, cleanStepsOf(read, body.branches)));
|
|
2649
|
+
return {
|
|
2650
|
+
ok: true,
|
|
2651
|
+
reads: all.reads,
|
|
2652
|
+
projects,
|
|
2653
|
+
digest: digestOf(projects)
|
|
2654
|
+
};
|
|
2655
|
+
};
|
|
2656
|
+
return {
|
|
2657
|
+
worktrees: async ({ query }) => {
|
|
2658
|
+
const all = await readAll(query.project);
|
|
2659
|
+
if (!all.ok) return all.refusal;
|
|
2660
|
+
return ok({ projects: all.reads.map((read) => listed(context, read)) });
|
|
2661
|
+
},
|
|
2662
|
+
worktreeRemovePlan: async ({ body }) => {
|
|
2663
|
+
const planned = await removePlanOf(context, body);
|
|
2664
|
+
return planned.ok ? ok(planned.answer) : planned.refusal;
|
|
2665
|
+
},
|
|
2666
|
+
worktreeRemove: async ({ body }) => {
|
|
2667
|
+
const removed = await removeIt(context, body, busy);
|
|
2668
|
+
return removed.ok ? ok({ done: removed.done }) : removed.refusal;
|
|
2669
|
+
},
|
|
2670
|
+
worktreesCleanPlan: async ({ body }) => {
|
|
2671
|
+
const planned = await cleanPlan(body);
|
|
2672
|
+
if (!planned.ok) return planned.refusal;
|
|
2673
|
+
return ok({
|
|
2674
|
+
projects: planned.projects,
|
|
2675
|
+
digest: planned.digest
|
|
2676
|
+
});
|
|
2677
|
+
},
|
|
2678
|
+
worktreesClean: async ({ body }) => {
|
|
2679
|
+
const taken = projectsFor(context, body.project);
|
|
2680
|
+
if (!taken.ok) return taken.refusal;
|
|
2681
|
+
const held = taken.projects.find((p) => busy.has(p.root));
|
|
2682
|
+
if (held !== void 0) return fail(409, `A remove or clean is already running in ${held.name}.`);
|
|
2683
|
+
const roots = taken.projects.map((p) => p.root);
|
|
2684
|
+
for (const root of roots) busy.add(root);
|
|
2685
|
+
try {
|
|
2686
|
+
const planned = await cleanPlan(body);
|
|
2687
|
+
if (!planned.ok) return planned.refusal;
|
|
2688
|
+
if (planned.digest !== body.digest) return fail(409, PLAN_CHANGED);
|
|
2689
|
+
const projects = [];
|
|
2690
|
+
for (const read of planned.reads) projects.push(await cleanOne(read, body.branches));
|
|
2691
|
+
return ok({ projects });
|
|
2692
|
+
} finally {
|
|
2693
|
+
for (const root of roots) busy.delete(root);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
};
|
|
2698
|
+
// ../server/src/api-routes.ts
|
|
2699
|
+
/** @file Every JSON route's handler, in one place. */
|
|
2700
|
+
const apiRoutes = (context, port) => ({
|
|
2701
|
+
...readRoutes(context),
|
|
2702
|
+
...writeRoutes(context),
|
|
2703
|
+
...builderRoutes(context),
|
|
2704
|
+
...contextRoutes(context),
|
|
2705
|
+
...feedbackRoutes(context),
|
|
2706
|
+
...worktreesRoutes(context),
|
|
2707
|
+
...updateRoutes(context, port)
|
|
2708
|
+
});
|
|
2709
|
+
const PLACEHOLDER_HTML = `<!doctype html>
|
|
2710
|
+
<html lang="en">
|
|
2711
|
+
<head>
|
|
2712
|
+
<meta charset="utf-8">
|
|
2713
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2714
|
+
<title>Ork</title>
|
|
2715
|
+
<style>
|
|
2716
|
+
:root { color-scheme: light dark; --fg: #1d1f23; --muted: #6b7079; --bg: #fbfbfa; --line: #e3e3e0; }
|
|
2717
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #e6e6e3; --muted: #9a9ea6; --bg: #16171a; --line: #2a2c31; } }
|
|
2718
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; }
|
|
2719
|
+
main { max-width: 760px; margin: 0 auto; padding: 40px 16px; }
|
|
2720
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
2721
|
+
h2 { font-size: 15px; margin: 28px 0 8px; }
|
|
2722
|
+
p, .muted { color: var(--muted); }
|
|
2723
|
+
ul { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--line); }
|
|
2724
|
+
li { padding: 8px 0; border-bottom: 1px solid var(--line); font-family: ui-monospace, monospace; font-size: 13px; overflow-wrap: anywhere; }
|
|
2725
|
+
code { font-family: ui-monospace, monospace; }
|
|
2726
|
+
</style>
|
|
2727
|
+
</head>
|
|
2728
|
+
<body>
|
|
2729
|
+
<main>
|
|
2730
|
+
<h1>Ork</h1>
|
|
2731
|
+
<p id="status">The server is running. The UI's panels come in the next releases; until then, this page lists what the API sees.</p>
|
|
2732
|
+
<h2>Open decisions</h2>
|
|
2733
|
+
<ul id="decisions"><li class="muted">Loading…</li></ul>
|
|
2734
|
+
<h2>Runs</h2>
|
|
2735
|
+
<ul id="runs"><li class="muted">Loading…</li></ul>
|
|
2736
|
+
<p>Answer with <code>ork decide</code>; follow a run with <code>ork logs <run> -f</code>.</p>
|
|
2737
|
+
</main>
|
|
2738
|
+
<script src="/placeholder.js"><\/script>
|
|
2739
|
+
</body>
|
|
2740
|
+
</html>
|
|
2741
|
+
`;
|
|
2742
|
+
const PLACEHOLDER_JS = `"use strict";
|
|
2743
|
+
const fill = (id, items, line, empty) => {
|
|
2744
|
+
const list = document.getElementById(id);
|
|
2745
|
+
list.replaceChildren();
|
|
2746
|
+
if (items.length === 0) items = [null];
|
|
2747
|
+
for (const item of items) {
|
|
2748
|
+
const li = document.createElement("li");
|
|
2749
|
+
li.textContent = item === null ? empty : line(item);
|
|
2750
|
+
if (item === null) li.className = "muted";
|
|
2751
|
+
list.append(li);
|
|
2752
|
+
}
|
|
2753
|
+
};
|
|
2754
|
+
fetch("/api/health").then(r => r.json()).then(h => {
|
|
2755
|
+
document.title = "Ork " + h.version;
|
|
2756
|
+
});
|
|
2757
|
+
const events = new EventSource("/api/stream");
|
|
2758
|
+
events.addEventListener("runs", e => fill("runs", JSON.parse(e.data),
|
|
2759
|
+
r => r.id + " " + r.live.padEnd(9) + " " + r.workflow + " " + r.startedAt.slice(0, 16).replace("T", " "),
|
|
2760
|
+
"No runs yet."));
|
|
2761
|
+
events.addEventListener("decisions", e => fill("decisions", JSON.parse(e.data),
|
|
2762
|
+
d => d.id + " " + d.node + " " + d.question + " [" + d.options.map(o => o.id).join(", ") + "]",
|
|
2763
|
+
"None."));
|
|
2764
|
+
events.onerror = () => {
|
|
2765
|
+
document.getElementById("status").textContent = "Lost the server; it may have stopped (start it with ork ui).";
|
|
2766
|
+
};
|
|
2767
|
+
`;
|
|
2768
|
+
// ../server/src/page.ts
|
|
2769
|
+
/**
|
|
2770
|
+
* No inline script and no `eval`. Inline `style` attributes are allowed
|
|
2771
|
+
* (`style-src-attr`): the UI's panels, popups and graph position themselves
|
|
2772
|
+
* with them; style sheets still come only from the server.
|
|
2773
|
+
*/
|
|
2774
|
+
const PAGE_POLICY = [
|
|
2775
|
+
"default-src 'self'",
|
|
2776
|
+
"script-src 'self'",
|
|
2777
|
+
"style-src 'self'",
|
|
2778
|
+
"style-src-attr 'unsafe-inline'",
|
|
2779
|
+
"font-src 'self'",
|
|
2780
|
+
"img-src 'self' data:",
|
|
2781
|
+
"connect-src 'self'",
|
|
2782
|
+
"frame-ancestors 'none'",
|
|
2783
|
+
"base-uri 'none'",
|
|
2784
|
+
"form-action 'self'",
|
|
2785
|
+
"object-src 'none'"
|
|
2786
|
+
].join("; ");
|
|
2787
|
+
const send = (res, status, type, body, cache) => {
|
|
2788
|
+
res.writeHead(status, {
|
|
2789
|
+
...BASE_HEADERS,
|
|
2790
|
+
...cache === void 0 ? {} : { "cache-control": cache },
|
|
2791
|
+
"content-security-policy": PAGE_POLICY,
|
|
2792
|
+
"content-type": type
|
|
2793
|
+
}).end(body);
|
|
2794
|
+
};
|
|
2795
|
+
/** A refusal for a person at the browser, not for a script. */
|
|
2796
|
+
const sendNotice = (res, status, text) => {
|
|
2797
|
+
send(res, status, "text/plain; charset=utf-8", `${text}\n`);
|
|
2798
|
+
};
|
|
2799
|
+
const sendPlaceholder = (res, pathname) => {
|
|
2800
|
+
if (pathname === "/placeholder.js") send(res, 200, "text/javascript; charset=utf-8", PLACEHOLDER_JS);
|
|
2801
|
+
else if (pathname === "/") send(res, 200, "text/html; charset=utf-8", PLACEHOLDER_HTML);
|
|
2802
|
+
else sendNotice(res, 404, `Nothing at ${pathname}.`);
|
|
2803
|
+
};
|
|
2804
|
+
/**
|
|
2805
|
+
* Answers a page request from the built UI in `uiDir` (or the placeholder
|
|
2806
|
+
* without one): the file, or 404 / 405.
|
|
2807
|
+
*/
|
|
2808
|
+
const sendPage = (res, method, pathname, uiDir) => {
|
|
2809
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
2810
|
+
sendNotice(res, 405, `${method} is not allowed here.`);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
if (uiDir === void 0) {
|
|
2814
|
+
sendPlaceholder(res, pathname);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
const asset = readAsset(uiDir, pathname);
|
|
2818
|
+
if (asset === null) sendNotice(res, 404, `Nothing at ${pathname}.`);
|
|
2819
|
+
else send(res, 200, asset.type, asset.body, asset.immutable ? "public, max-age=31536000, immutable" : void 0);
|
|
2820
|
+
};
|
|
2821
|
+
// ../server/src/streams.ts
|
|
2822
|
+
/** How often the overview stream compares the store. */
|
|
2823
|
+
const OVERVIEW_INTERVAL_MS = 1e3;
|
|
2824
|
+
/** How many runs the overview stream sends. */
|
|
2825
|
+
const OVERVIEW_RUNS = 50;
|
|
2826
|
+
/** The seq to start after: `Last-Event-ID`, else `?after=`, else 0. */
|
|
2827
|
+
const startAfter = (req, url) => {
|
|
2828
|
+
const header = req.headers["last-event-id"];
|
|
2829
|
+
const raw = (Array.isArray(header) ? header[0] : header) ?? url.searchParams.get("after") ?? "0";
|
|
2830
|
+
const after = Number(raw);
|
|
2831
|
+
return Number.isInteger(after) && after >= 0 ? after : null;
|
|
2832
|
+
};
|
|
2833
|
+
const runStream = async (context, req, res, url, id, shutdown) => {
|
|
2834
|
+
const found = runOf(context, id);
|
|
2835
|
+
if (!found.ok) {
|
|
2836
|
+
sendError(res, found.refusal.status, found.refusal.error);
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2839
|
+
const after = startAfter(req, url);
|
|
2840
|
+
if (after === null) {
|
|
2841
|
+
sendError(res, 400, "Last-Event-ID or after must be a sequence number.");
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
const stream = openEventStream(res);
|
|
2845
|
+
const end = await followEvents(context.store, found.run.id, (stored) => {
|
|
2846
|
+
stream.send("run-event", stored, stored.seq);
|
|
2847
|
+
}, {
|
|
2848
|
+
follow: true,
|
|
2849
|
+
after,
|
|
2850
|
+
...context.home === void 0 ? {} : { home: context.home },
|
|
2851
|
+
signal: AbortSignal.any([stream.signal, shutdown])
|
|
2852
|
+
});
|
|
2853
|
+
if (end !== "aborted" && end !== "stored") stream.send("end", { reason: end });
|
|
2854
|
+
stream.close();
|
|
2855
|
+
};
|
|
2856
|
+
const overviewStream = (context, res, url, shutdown) => {
|
|
2857
|
+
const key = url.searchParams.get("project");
|
|
2858
|
+
const root = key === null ? void 0 : projectOf(context, key)?.root ?? key;
|
|
2859
|
+
const stream = openEventStream(res);
|
|
2860
|
+
const last = {
|
|
2861
|
+
runs: "",
|
|
2862
|
+
decisions: "",
|
|
2863
|
+
version: ""
|
|
2864
|
+
};
|
|
2865
|
+
const tick = () => {
|
|
2866
|
+
const filter = root === void 0 ? {} : { project: root };
|
|
2867
|
+
const runs = context.store.runs({
|
|
2868
|
+
...filter,
|
|
2869
|
+
limit: OVERVIEW_RUNS
|
|
2870
|
+
}).map((run) => viewOf$2(context, run));
|
|
2871
|
+
const decisions = pendingDecisions(context.store, filter);
|
|
2872
|
+
for (const [name, value] of [
|
|
2873
|
+
["runs", runs],
|
|
2874
|
+
["decisions", decisions],
|
|
2875
|
+
["version", context.updates.info()]
|
|
2876
|
+
]) {
|
|
2877
|
+
const text = JSON.stringify(value);
|
|
2878
|
+
if (text === last[name]) continue;
|
|
2879
|
+
last[name] = text;
|
|
2880
|
+
stream.send(name, value);
|
|
2881
|
+
}
|
|
2882
|
+
};
|
|
2883
|
+
tick();
|
|
2884
|
+
const timer = setInterval(tick, OVERVIEW_INTERVAL_MS);
|
|
2885
|
+
const stop = () => {
|
|
2886
|
+
clearInterval(timer);
|
|
2887
|
+
stream.close();
|
|
2888
|
+
};
|
|
2889
|
+
stream.signal.addEventListener("abort", stop);
|
|
2890
|
+
shutdown.addEventListener("abort", stop, { signal: stream.signal });
|
|
2891
|
+
};
|
|
2892
|
+
// ../server/src/upgrade-stream.ts
|
|
2893
|
+
const NO_JOB = "No upgrade has run since `ork ui` started.";
|
|
2894
|
+
const upgradeStream = (context, req, res, url, shutdown) => {
|
|
2895
|
+
const job = context.updates.job();
|
|
2896
|
+
if (job === null) {
|
|
2897
|
+
sendError(res, 404, NO_JOB);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const after = startAfter(req, url);
|
|
2901
|
+
if (after === null) {
|
|
2902
|
+
sendError(res, 400, "Last-Event-ID or after must be a sequence number.");
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
const stream = openEventStream(res);
|
|
2906
|
+
const { lines, dropped } = job.linesAfter(after);
|
|
2907
|
+
if (dropped) {
|
|
2908
|
+
const note = {
|
|
2909
|
+
seq: (lines[0]?.seq ?? after + 1) - 1,
|
|
2910
|
+
stream: "stderr",
|
|
2911
|
+
text: "Earlier lines dropped."
|
|
2912
|
+
};
|
|
2913
|
+
stream.send("output", note);
|
|
2914
|
+
}
|
|
2915
|
+
for (const line of lines) stream.send("output", line, line.seq);
|
|
2916
|
+
const ended = job.end();
|
|
2917
|
+
if (ended !== null) {
|
|
2918
|
+
stream.send("end", ended);
|
|
2919
|
+
stream.close();
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
const stop = job.listen((event) => {
|
|
2923
|
+
if (event.kind === "output") {
|
|
2924
|
+
stream.send("output", event.line, event.line.seq);
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
stream.send("end", event.end);
|
|
2928
|
+
stream.close();
|
|
2929
|
+
});
|
|
2930
|
+
stream.signal.addEventListener("abort", stop);
|
|
2931
|
+
shutdown.addEventListener("abort", () => {
|
|
2932
|
+
stream.close();
|
|
2933
|
+
}, { signal: stream.signal });
|
|
2934
|
+
};
|
|
2935
|
+
// ../server/src/dispatch.ts
|
|
2936
|
+
const RUN_STREAM = compilePath(STREAMS.run);
|
|
2937
|
+
const createDispatch = (context, access) => {
|
|
2938
|
+
const handlers = apiRoutes(context, access.port);
|
|
2939
|
+
return async (req, res) => {
|
|
2940
|
+
const method = req.method ?? "GET";
|
|
2941
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2942
|
+
const api = url.pathname.startsWith("/api/");
|
|
2943
|
+
const verdict = guard({
|
|
2944
|
+
method,
|
|
2945
|
+
url: req.url ?? "/",
|
|
2946
|
+
headers: req.headers
|
|
2947
|
+
}, {
|
|
2948
|
+
port: access.port(),
|
|
2949
|
+
token: access.token
|
|
2950
|
+
});
|
|
2951
|
+
if (verdict.kind === "refuse") {
|
|
2952
|
+
if (api) sendError(res, verdict.status, verdict.error);
|
|
2953
|
+
else sendNotice(res, verdict.status, verdict.error);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
if (verdict.kind === "exchange") {
|
|
2957
|
+
res.writeHead(302, {
|
|
2958
|
+
...BASE_HEADERS,
|
|
2959
|
+
location: verdict.location,
|
|
2960
|
+
"set-cookie": verdict.cookie
|
|
2961
|
+
}).end();
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const streamed = RUN_STREAM.pattern.exec(url.pathname);
|
|
2965
|
+
if (method === "GET" && streamed !== null) {
|
|
2966
|
+
const id = decodeURIComponent(streamed[1] ?? "");
|
|
2967
|
+
await runStream(context, req, res, url, id, access.shutdown);
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
if (method === "GET" && url.pathname === STREAMS.overview) {
|
|
2971
|
+
overviewStream(context, res, url, access.shutdown);
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (method === "GET" && url.pathname === STREAMS.upgrade) {
|
|
2975
|
+
upgradeStream(context, req, res, url, access.shutdown);
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
if (await route(handlers, req, res, url)) return;
|
|
2979
|
+
if (api) sendError(res, 404, `No API route ${url.pathname}.`);
|
|
2980
|
+
else sendPage(res, method, url.pathname, context.uiDir);
|
|
2981
|
+
};
|
|
2982
|
+
};
|
|
2983
|
+
// ../server/src/ui-file.ts
|
|
2984
|
+
/**
|
|
2985
|
+
* @file `~/.ork/ui.json`: the running server's port, pid, token and start time
|
|
2986
|
+
* (ADR-039). It is written with mode 0600 when the server starts, so a second
|
|
2987
|
+
* `ork ui` finds and opens the running one, and removed when it stops. A
|
|
2988
|
+
* record whose process is gone counts as none.
|
|
2989
|
+
*/
|
|
2990
|
+
const UiRecordSchema = z.object({
|
|
2991
|
+
port: z.int().min(1).max(65535),
|
|
2992
|
+
pid: z.int().positive(),
|
|
2993
|
+
token: z.string().min(1),
|
|
2994
|
+
startedAt: z.string()
|
|
2995
|
+
});
|
|
2996
|
+
/** Where the record lives; `home` is `~` (tests pass a folder). */
|
|
2997
|
+
const uiFilePath = (home = homedir()) => path.join(home, ".ork", "ui.json");
|
|
2998
|
+
/** The record, or `null` when there is none or it cannot be read. */
|
|
2999
|
+
const readUiFile = (file) => {
|
|
3000
|
+
try {
|
|
3001
|
+
const parsed = UiRecordSchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
|
|
3002
|
+
return parsed.success ? parsed.data : null;
|
|
3003
|
+
} catch {
|
|
3004
|
+
return null;
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
/** Writes the record readable by the user only, replacing any other. */
|
|
3008
|
+
const writeUiFile = (file, record) => {
|
|
3009
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
3010
|
+
const temporary = `${file}.${String(process.pid)}.tmp`;
|
|
3011
|
+
writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 384 });
|
|
3012
|
+
chmodSync(temporary, 384);
|
|
3013
|
+
renameSync(temporary, file);
|
|
3014
|
+
};
|
|
3015
|
+
/** Removes the record, if it is still the one of the server `pid`. */
|
|
3016
|
+
const removeUiFile = (file, pid) => {
|
|
3017
|
+
if (readUiFile(file)?.pid === pid) rmSync(file, { force: true });
|
|
3018
|
+
};
|
|
3019
|
+
/** The record of a server that is still running, or `null`. */
|
|
3020
|
+
const runningServer = (file) => {
|
|
3021
|
+
const record = readUiFile(file);
|
|
3022
|
+
return record !== null && processAlive(record.pid) ? record : null;
|
|
3023
|
+
};
|
|
3024
|
+
// ../server/src/server.ts
|
|
3025
|
+
/**
|
|
3026
|
+
* @file Starts the local server (ADR-039, specs/server.md): `node:http` on
|
|
3027
|
+
* loopback only, every request through the guards, then the event streams,
|
|
3028
|
+
* the JSON routes, and the page. It opens the store as one more client and
|
|
3029
|
+
* runs nothing itself; it records itself in `~/.ork/ui.json` while it runs.
|
|
3030
|
+
*/
|
|
3031
|
+
/** The port `ork ui` serves on unless told otherwise. */
|
|
3032
|
+
const DEFAULT_PORT = 4417;
|
|
3033
|
+
const listen = (server, port) => new Promise((resolve, reject) => {
|
|
3034
|
+
server.once("error", (error) => {
|
|
3035
|
+
reject(error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`Port ${String(port)} is in use; start Ork's UI on another with \`--port <n>\`.`) : error);
|
|
3036
|
+
});
|
|
3037
|
+
server.listen(port, "127.0.0.1", () => {
|
|
3038
|
+
resolve(server.address().port);
|
|
3039
|
+
});
|
|
3040
|
+
});
|
|
3041
|
+
const startServer = async (options) => {
|
|
3042
|
+
const store = openStore(storePath(options.home));
|
|
3043
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3044
|
+
const uiDir = options.ui?.find((dir) => hasUi(dir));
|
|
3045
|
+
const updates = createUpdates({
|
|
3046
|
+
version: options.version,
|
|
3047
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
3048
|
+
...options.update === void 0 ? {} : { options: options.update }
|
|
3049
|
+
});
|
|
3050
|
+
const context = {
|
|
3051
|
+
store,
|
|
3052
|
+
version: options.version,
|
|
3053
|
+
pid: process.pid,
|
|
3054
|
+
startedAt,
|
|
3055
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
3056
|
+
...options.script === void 0 ? {} : { script: options.script },
|
|
3057
|
+
...uiDir === void 0 ? {} : { uiDir },
|
|
3058
|
+
...options.gh === void 0 ? {} : { gh: options.gh },
|
|
3059
|
+
updates,
|
|
3060
|
+
...options.restart === void 0 ? {} : { restart: options.restart }
|
|
3061
|
+
};
|
|
3062
|
+
const token = options.token ?? newToken();
|
|
3063
|
+
const shutdown = new AbortController();
|
|
3064
|
+
const log = options.log ?? ((line) => {
|
|
3065
|
+
process.stderr.write(`${line}\n`);
|
|
3066
|
+
});
|
|
3067
|
+
let port = options.port ?? 4417;
|
|
3068
|
+
const handle = createDispatch(context, {
|
|
3069
|
+
port: () => port,
|
|
3070
|
+
token,
|
|
3071
|
+
shutdown: shutdown.signal
|
|
3072
|
+
});
|
|
3073
|
+
const server = createServer((req, res) => {
|
|
3074
|
+
handle(req, res).catch((error) => {
|
|
3075
|
+
log(`ork ui: ${req.method ?? ""} ${req.url?.split("?")[0] ?? ""} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3076
|
+
if (res.headersSent) res.destroy();
|
|
3077
|
+
else sendError(res, 500, "The server failed; see its terminal.");
|
|
3078
|
+
});
|
|
3079
|
+
});
|
|
3080
|
+
try {
|
|
3081
|
+
port = await listen(server, port);
|
|
3082
|
+
} catch (error) {
|
|
3083
|
+
updates.close();
|
|
3084
|
+
store.close();
|
|
3085
|
+
throw error;
|
|
3086
|
+
}
|
|
3087
|
+
const file = uiFilePath(options.home);
|
|
3088
|
+
if (options.record !== false) writeUiFile(file, {
|
|
3089
|
+
port,
|
|
3090
|
+
pid: process.pid,
|
|
3091
|
+
token,
|
|
3092
|
+
startedAt
|
|
3093
|
+
});
|
|
3094
|
+
let closed;
|
|
3095
|
+
return {
|
|
3096
|
+
port,
|
|
3097
|
+
url: `http://127.0.0.1:${String(port)}`,
|
|
3098
|
+
token,
|
|
3099
|
+
upgrading: () => {
|
|
3100
|
+
const job = updates.job();
|
|
3101
|
+
return job === null || job.end() !== null ? null : job.done;
|
|
3102
|
+
},
|
|
3103
|
+
close: () => {
|
|
3104
|
+
closed ??= new Promise((resolve) => {
|
|
3105
|
+
shutdown.abort();
|
|
3106
|
+
updates.close();
|
|
3107
|
+
server.close(() => {
|
|
3108
|
+
store.close();
|
|
3109
|
+
if (options.record !== false) removeUiFile(file, process.pid);
|
|
3110
|
+
resolve();
|
|
3111
|
+
});
|
|
3112
|
+
server.closeAllConnections();
|
|
3113
|
+
});
|
|
3114
|
+
return closed;
|
|
3115
|
+
}
|
|
3116
|
+
};
|
|
3117
|
+
};
|
|
3118
|
+
export { openMcp as a, refused$1 as c, uiFilePath as i, newToken as l, startServer as n, tool as o, runningServer as r, answer as s, DEFAULT_PORT as t };
|