@krapht/ork 0.3.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.
Files changed (31) hide show
  1. package/dist/__build_info__-CtSVVuZE.mjs +691 -0
  2. package/dist/bin.mjs +826 -526
  3. package/dist/clean-Dcr807be.mjs +88 -0
  4. package/dist/{context-BFGC_C02.mjs → context-C_bksb0C.mjs} +45 -129
  5. package/dist/{find-BcvhT6UN.mjs → find-DptmbhGd.mjs} +1 -1
  6. package/dist/{format-DqpObyMl.mjs → format-J-a21Yvn.mjs} +3 -163
  7. package/dist/{in-a-run-BbLhZOFW.mjs → in-a-run-D1Qc5Mit.mjs} +1 -1
  8. package/dist/{limits-BKYAmqCR.mjs → limits-BDZCmGEu.mjs} +1 -1
  9. package/dist/list-ZvY2OcLq.mjs +110 -0
  10. package/dist/{mcp-BWsdQyri.mjs → mcp-CHVKKg3B.mjs} +6 -6
  11. package/dist/{output-Q2EdeVUt.mjs → output-CXSJO00X.mjs} +68 -24
  12. package/dist/{pause-DKACDfV1.mjs → pause-BPSmg4yz.mjs} +4 -4
  13. package/dist/{relay-CbK-Wme9.mjs → relay-Do-aNbSD.mjs} +2 -2
  14. package/dist/remove-AwaMMaHf.mjs +194 -0
  15. package/dist/{send-C6yr04Fe.mjs → send-DhGBkowy.mjs} +3 -3
  16. package/dist/{src-CZOVF2yU.mjs → src-BHnxpJyH.mjs} +1 -1
  17. package/dist/{src-Cz1yOUTg.mjs → src-CGqMati3.mjs} +1411 -747
  18. package/dist/{src-D5ruZFgD.mjs → src-CpDKIulI.mjs} +1200 -55
  19. package/dist/{src-DtN1k-B3.mjs → src-UWkk1sl9.mjs} +1 -1
  20. package/dist/ui/assets/app-BgVDC_5l.js +67 -0
  21. package/dist/ui/assets/{app-C722xNZ3.css → app-DVf68fj5.css} +1 -1
  22. package/dist/ui/assets/{index-BoG6O-Lp.js → index-MQNNgsyT.js} +2 -2
  23. package/dist/ui/index.html +1 -1
  24. package/dist/ui-DhutUMgQ.mjs +472 -0
  25. package/dist/{usage-BIjf9Xdt.mjs → usage-CT7Z-yjr.mjs} +5 -4
  26. package/dist/{watch-B8o4Szd_.mjs → watch-0Qr2UfCQ.mjs} +6 -6
  27. package/dist/worktrees-DHl80k96.mjs +212 -0
  28. package/package.json +4 -4
  29. package/dist/__build_info__-CXXMLvjt.mjs +0 -104
  30. package/dist/ui/assets/app-BlDdP396.js +0 -61
  31. package/dist/ui-CZS1XKla.mjs +0 -160
@@ -1,12 +1,14 @@
1
- import { B as requestContinue, F as linkNodes, H as requestStop, Kt as loadAgents, Nt as readCondition, Qt as placeFor, R as unlinkNodes, Sn as NodeSchema, U as tellLiveRun, V as requestPause, X as detachResume, Z as resumable, Zt as placeFiles, at as processAlive, bn as expandEdge, cn as skillsIn, ct as checkWorkflowText, d as triggersReport, dn as newWorkflowText, en as runDirOf, fn as readDocument, it as pausedOf, ln as WorkflowEditSchema, lt as walkChecks, mn as parseWorkflow, nn as workflowDirOf, nt as followEvents, o as limitsReport, ot as runStatus, pn as renderJsonSchema, r as capsHere, rn as inside, rt as liveStatus, st as runTree, tt as detachRun, un as applyEdits, ut as walkSummary, vn as EdgeInputSchema, yt as nodeSays } from "./src-Cz1yOUTg.mjs";
2
- import { a as pendingDecisions, i as answerDecision, r as nodeSession } from "./__build_info__-CXXMLvjt.mjs";
3
- import { n as newRunId, t as findRun } from "./find-BcvhT6UN.mjs";
4
- import { c as openStore, l as storePath, s as nodeOf } from "./output-Q2EdeVUt.mjs";
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
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";
6
8
  import path from "node:path";
7
9
  import { z } from "zod";
8
10
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
11
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
10
12
  import { homedir } from "node:os";
11
13
  import { createServer } from "node:http";
12
14
  // ../core/src/store/activity.ts
@@ -133,6 +135,194 @@ const BUILDER_API = {
133
135
  body: SaveWorkflowSchema
134
136
  }
135
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
+ };
136
326
  // ../server/src/contract.ts
137
327
  const Count = z.coerce.number().int().positive();
138
328
  const Seq = z.coerce.number().int().nonnegative();
@@ -179,17 +369,6 @@ const MessageSchema = z.strictObject({
179
369
  node: z.string().min(1),
180
370
  body: z.string().min(1)
181
371
  });
182
- /** Two nodes of live runs, each `<run>/<node>` (RFC-003). */
183
- const LinkSchema = z.strictObject({
184
- a: z.string().min(3),
185
- b: z.string().min(3),
186
- maxMessages: z.number().int().positive().optional()
187
- });
188
- const UnlinkSchema = z.strictObject({
189
- a: z.string().min(3),
190
- b: z.string().min(3)
191
- });
192
- const LinksQuerySchema = z.strictObject({ run: z.string().min(1).optional() });
193
372
  /** Pause, continue or stop: the whole run, or `node` alone (ADR-041). */
194
373
  const ControlSchema = z.strictObject({ node: z.string().min(1).optional() });
195
374
  /** Every JSON route; the two event streams are in {@link STREAMS}. */
@@ -199,6 +378,10 @@ const API = {
199
378
  path: "/api/health"
200
379
  },
201
380
  ...BUILDER_API,
381
+ ...CONTEXT_API,
382
+ ...FEEDBACK_API,
383
+ ...WORKTREES_API,
384
+ ...UPDATE_API,
202
385
  projects: {
203
386
  method: "GET",
204
387
  path: "/api/projects"
@@ -282,21 +465,7 @@ const API = {
282
465
  method: "GET",
283
466
  path: "/api/triggers"
284
467
  },
285
- links: {
286
- method: "GET",
287
- path: "/api/links",
288
- query: LinksQuerySchema
289
- },
290
- link: {
291
- method: "POST",
292
- path: "/api/links",
293
- body: LinkSchema
294
- },
295
- unlink: {
296
- method: "POST",
297
- path: "/api/links/end",
298
- body: UnlinkSchema
299
- }
468
+ ...LINKS_API
300
469
  };
301
470
  /** The Server-Sent Event streams, and the events each sends. */
302
471
  const STREAMS = {
@@ -305,8 +474,12 @@ const STREAMS = {
305
474
  * `Last-Event-ID`, or `?after=`.
306
475
  */
307
476
  run: "/api/runs/:id/stream",
308
- /** `runs` and `decisions`, each when it changes; `?project=` keeps one. */
309
- overview: "/api/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"
310
483
  };
311
484
  // ../server/src/graph.ts
312
485
  /**
@@ -361,8 +534,9 @@ const runGraph = (events, file) => {
361
534
  };
362
535
  // ../server/src/guards.ts
363
536
  /**
364
- * @file What the server lets in (ADR-039, specs/server.md#security): only its
365
- * own loopback `Host` (against DNS rebinding); only with its token, from the
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
366
540
  * session cookie or an `Authorization: Bearer` header; and, for a write sent
367
541
  * with the cookie, only from its own `Origin` (against CSRF). The token in
368
542
  * `?token=` is swapped once for the cookie, with a redirect that takes it out
@@ -683,14 +857,14 @@ const shrink = (value, longest = LONGEST) => {
683
857
  return value;
684
858
  };
685
859
  const answer = (value) => ({ text: JSON.stringify(shrink(value)) });
686
- const refused = (error) => ({
860
+ const refused$1 = (error) => ({
687
861
  text: error,
688
862
  isError: true
689
863
  });
690
864
  /** A route's reply as an answer, `then` shaping what it gave. */
691
865
  const fromReply = async (reply, then = (body) => body) => {
692
866
  const settled = await reply;
693
- return "error" in settled ? refused(settled.error) : answer(then(settled.body));
867
+ return "error" in settled ? refused$1(settled.error) : answer(then(settled.body));
694
868
  };
695
869
  const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
696
870
  /** A field of an object, or `undefined`. */
@@ -721,7 +895,7 @@ const formatPart = (nodeType) => {
721
895
  }
722
896
  });
723
897
  const branch = branches.find((item) => typeOf(item) === nodeType);
724
- return branch === void 0 ? refused(`No node type \`${nodeType}\`. The types are: ${types.map((type) => `\`${type}\``).join(", ")}.`) : { text: JSON.stringify(branch) };
898
+ return branch === void 0 ? refused$1(`No node type \`${nodeType}\`. The types are: ${types.map((type) => `\`${type}\``).join(", ")}.`) : { text: JSON.stringify(branch) };
725
899
  };
726
900
  // ../server/src/mcp-tool.ts
727
901
  /**
@@ -738,13 +912,13 @@ const tool = (name, description, shape, run, options = {}) => {
738
912
  readOnly: options.readOnly ?? true,
739
913
  call: (args) => {
740
914
  const parsed = schema.safeParse(args ?? {});
741
- return Promise.resolve(parsed.success ? run(parsed.data) : refused(z.prettifyError(parsed.error)));
915
+ return Promise.resolve(parsed.success ? run(parsed.data) : refused$1(z.prettifyError(parsed.error)));
742
916
  }
743
917
  };
744
918
  };
745
919
  // ../server/src/write-routes.ts
746
920
  /**
747
- * @file The API's write routes (specs/server.md#api): answer a decision; stop,
921
+ * @file The API's write routes (specs/server/api.md): answer a decision; stop,
748
922
  * pause or continue a run or one of its nodes (ADR-041); message a live run's
749
923
  * node; and start or resume a run in the background. Each does what its CLI
750
924
  * command does (`ork decide --id`, `stop`, `pause`, `continue`, `chat -m`,
@@ -882,11 +1056,11 @@ const textOf = (context, key, args) => {
882
1056
  const place = projectOf(context, key);
883
1057
  if (place === void 0) return {
884
1058
  ok: false,
885
- answer: refused(`No registered project \`${key}\`.`)
1059
+ answer: refused$1(`No registered project \`${key}\`.`)
886
1060
  };
887
1061
  if (args.workflow === void 0 === (args.text === void 0)) return {
888
1062
  ok: false,
889
- answer: refused("Give either `workflow` (a name or path) or `text`.")
1063
+ answer: refused$1("Give either `workflow` (a name or path) or `text`.")
890
1064
  };
891
1065
  if (args.text !== void 0) {
892
1066
  const file = path.join(placeFiles(place).workflows, "draft.ork.yaml");
@@ -900,7 +1074,7 @@ const textOf = (context, key, args) => {
900
1074
  const file = workflowFile(place, args.workflow ?? "");
901
1075
  return file === null ? {
902
1076
  ok: false,
903
- answer: refused(`No workflow \`${args.workflow ?? ""}\` in \`${place.id}\`'s workflows or inside its root.`)
1077
+ answer: refused$1(`No workflow \`${args.workflow ?? ""}\` in \`${place.id}\`'s workflows or inside its root.`)
904
1078
  } : {
905
1079
  ok: true,
906
1080
  text: readFileSync(file, "utf8"),
@@ -1282,7 +1456,7 @@ const saveDraft = (context, project, name, force) => {
1282
1456
  };
1283
1457
  // ../server/src/builder-routes.ts
1284
1458
  /**
1285
- * @file The workflow builder's routes (RFC-008, specs/server.md#api). Each
1459
+ * @file The workflow builder's routes (RFC-008, specs/server/api.md). Each
1286
1460
  * finds the project, then does its work in `builder.ts`, and a refusal has
1287
1461
  * the words the user will see.
1288
1462
  */
@@ -1383,7 +1557,7 @@ const projectWorkflows = (project) => {
1383
1557
  };
1384
1558
  // ../server/src/read-routes.ts
1385
1559
  /**
1386
- * @file The API's read routes (specs/server.md#api): health, projects and a
1560
+ * @file The API's read routes (specs/server/api.md): health, projects and a
1387
1561
  * project's workflows, runs, a run, its tree and its events, the open
1388
1562
  * decisions, and usage. Each reads the store as the matching CLI command does
1389
1563
  * (`ork projects`, `ps`, `tree`, `logs`, `decide --list`, `usage`).
@@ -1498,7 +1672,7 @@ const mcpTools = (context) => {
1498
1672
  ...NO_QUERY,
1499
1673
  params: { id: args.run }
1500
1674
  });
1501
- if ("error" in view) return refused(view.error);
1675
+ if ("error" in view) return refused$1(view.error);
1502
1676
  const tree = read.tree({
1503
1677
  ...NO_QUERY,
1504
1678
  params: { id: view.body.id }
@@ -1535,6 +1709,89 @@ const mcpTools = (context) => {
1535
1709
  })))
1536
1710
  ];
1537
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
+ };
1538
1795
  // ../server/src/mcp.ts
1539
1796
  /**
1540
1797
  * @file What `ork mcp` opens (RFC-012): the store under a home, and the tools
@@ -1550,7 +1807,8 @@ const openMcp = (options) => {
1550
1807
  pid: process.pid,
1551
1808
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1552
1809
  ...options.home === void 0 ? {} : { home: options.home },
1553
- ...options.script === void 0 ? {} : { script: options.script }
1810
+ ...options.script === void 0 ? {} : { script: options.script },
1811
+ updates: createUpdates({ version: options.version })
1554
1812
  };
1555
1813
  return {
1556
1814
  tools: [...mcpTools(context), ...actingTools(context, { start: options.start })],
@@ -1620,12 +1878,833 @@ const readAsset = (dir, pathname) => {
1620
1878
  return null;
1621
1879
  }
1622
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
+ };
1623
2698
  // ../server/src/api-routes.ts
1624
2699
  /** @file Every JSON route's handler, in one place. */
1625
- const apiRoutes = (context) => ({
2700
+ const apiRoutes = (context, port) => ({
1626
2701
  ...readRoutes(context),
1627
2702
  ...writeRoutes(context),
1628
- ...builderRoutes(context)
2703
+ ...builderRoutes(context),
2704
+ ...contextRoutes(context),
2705
+ ...feedbackRoutes(context),
2706
+ ...worktreesRoutes(context),
2707
+ ...updateRoutes(context, port)
1629
2708
  });
1630
2709
  const PLACEHOLDER_HTML = `<!doctype html>
1631
2710
  <html lang="en">
@@ -1780,7 +2859,8 @@ const overviewStream = (context, res, url, shutdown) => {
1780
2859
  const stream = openEventStream(res);
1781
2860
  const last = {
1782
2861
  runs: "",
1783
- decisions: ""
2862
+ decisions: "",
2863
+ version: ""
1784
2864
  };
1785
2865
  const tick = () => {
1786
2866
  const filter = root === void 0 ? {} : { project: root };
@@ -1789,7 +2869,11 @@ const overviewStream = (context, res, url, shutdown) => {
1789
2869
  limit: OVERVIEW_RUNS
1790
2870
  }).map((run) => viewOf$2(context, run));
1791
2871
  const decisions = pendingDecisions(context.store, filter);
1792
- for (const [name, value] of [["runs", runs], ["decisions", decisions]]) {
2872
+ for (const [name, value] of [
2873
+ ["runs", runs],
2874
+ ["decisions", decisions],
2875
+ ["version", context.updates.info()]
2876
+ ]) {
1793
2877
  const text = JSON.stringify(value);
1794
2878
  if (text === last[name]) continue;
1795
2879
  last[name] = text;
@@ -1805,10 +2889,53 @@ const overviewStream = (context, res, url, shutdown) => {
1805
2889
  stream.signal.addEventListener("abort", stop);
1806
2890
  shutdown.addEventListener("abort", stop, { signal: stream.signal });
1807
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
+ };
1808
2935
  // ../server/src/dispatch.ts
1809
2936
  const RUN_STREAM = compilePath(STREAMS.run);
1810
2937
  const createDispatch = (context, access) => {
1811
- const handlers = apiRoutes(context);
2938
+ const handlers = apiRoutes(context, access.port);
1812
2939
  return async (req, res) => {
1813
2940
  const method = req.method ?? "GET";
1814
2941
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -1844,6 +2971,10 @@ const createDispatch = (context, access) => {
1844
2971
  overviewStream(context, res, url, access.shutdown);
1845
2972
  return;
1846
2973
  }
2974
+ if (method === "GET" && url.pathname === STREAMS.upgrade) {
2975
+ upgradeStream(context, req, res, url, access.shutdown);
2976
+ return;
2977
+ }
1847
2978
  if (await route(handlers, req, res, url)) return;
1848
2979
  if (api) sendError(res, 404, `No API route ${url.pathname}.`);
1849
2980
  else sendPage(res, method, url.pathname, context.uiDir);
@@ -1911,6 +3042,11 @@ const startServer = async (options) => {
1911
3042
  const store = openStore(storePath(options.home));
1912
3043
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1913
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
+ });
1914
3050
  const context = {
1915
3051
  store,
1916
3052
  version: options.version,
@@ -1918,9 +3054,12 @@ const startServer = async (options) => {
1918
3054
  startedAt,
1919
3055
  ...options.home === void 0 ? {} : { home: options.home },
1920
3056
  ...options.script === void 0 ? {} : { script: options.script },
1921
- ...uiDir === void 0 ? {} : { uiDir }
3057
+ ...uiDir === void 0 ? {} : { uiDir },
3058
+ ...options.gh === void 0 ? {} : { gh: options.gh },
3059
+ updates,
3060
+ ...options.restart === void 0 ? {} : { restart: options.restart }
1922
3061
  };
1923
- const token = newToken();
3062
+ const token = options.token ?? newToken();
1924
3063
  const shutdown = new AbortController();
1925
3064
  const log = options.log ?? ((line) => {
1926
3065
  process.stderr.write(`${line}\n`);
@@ -1941,6 +3080,7 @@ const startServer = async (options) => {
1941
3080
  try {
1942
3081
  port = await listen(server, port);
1943
3082
  } catch (error) {
3083
+ updates.close();
1944
3084
  store.close();
1945
3085
  throw error;
1946
3086
  }
@@ -1956,9 +3096,14 @@ const startServer = async (options) => {
1956
3096
  port,
1957
3097
  url: `http://127.0.0.1:${String(port)}`,
1958
3098
  token,
3099
+ upgrading: () => {
3100
+ const job = updates.job();
3101
+ return job === null || job.end() !== null ? null : job.done;
3102
+ },
1959
3103
  close: () => {
1960
3104
  closed ??= new Promise((resolve) => {
1961
3105
  shutdown.abort();
3106
+ updates.close();
1962
3107
  server.close(() => {
1963
3108
  store.close();
1964
3109
  if (options.record !== false) removeUiFile(file, process.pid);
@@ -1970,4 +3115,4 @@ const startServer = async (options) => {
1970
3115
  }
1971
3116
  };
1972
3117
  };
1973
- export { openMcp as a, refused as c, uiFilePath as i, startServer as n, tool as o, runningServer as r, answer as s, DEFAULT_PORT as t };
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 };