@dev.fast/whiteboard 0.3.3 → 0.3.4-preview.20261019.125

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 (115) hide show
  1. package/dist/{account-alias-xOdmMnDH.js → account-alias-ClRtWF_-.js} +8 -6
  2. package/dist/{agent-cli-DT7MdaqL.js → agent-cli-C6AM9dml.js} +15 -10
  3. package/dist/agent-cli-DKpWo-4F.js +2 -0
  4. package/dist/{agent-client-BRBzv3AV.js → agent-client-G4KxB8zB.js} +13 -3
  5. package/dist/{authoring-tools-DGSEag8y.js → authoring-tools-Dfo0uOSj.js} +34 -27
  6. package/dist/{background-server-WKzI_a98.js → background-server-BF_lLFxY.js} +5 -5
  7. package/dist/{background-server-C_REWFkG.js → background-server-DJfYWRoN.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-BBSDi_dX.js → cli-CqOiiTW_.js} +1 -1
  10. package/dist/{cli-runner-DshTBske.js → cli-runner-D8CsXff4.js} +26 -26
  11. package/dist/cli.js +2 -2
  12. package/dist/{client-BN_FDxFr.js → client-lKVF_Elo.js} +253 -47
  13. package/dist/{fuzzy-match-bFX6wm8j.js → fuzzy-match-DH9lZivo.js} +42 -14
  14. package/dist/{headless-host-CS4Gv0c5.js → headless-host-C4bhVPZx.js} +513 -140
  15. package/dist/{headless-host-CzvYpKOX.js → headless-host-CeNnXxiL.js} +1 -1
  16. package/dist/lens-selection-CyJapcsl.js +214 -0
  17. package/dist/{local-data-DtQTIzHX.js → local-data-BdqqCYUD.js} +2434 -328
  18. package/dist/{mcp-DpKnnmHH.js → mcp-B2mvDEhC.js} +5 -9
  19. package/dist/{package-paths-jMg6JxDP.js → package-paths-Cm1tR8c4.js} +1 -1
  20. package/dist/{profile-DaqmODhc.js → profile-DcigfSms.js} +20 -8
  21. package/dist/profile-DthRnZ7x.js +2 -0
  22. package/dist/{remote-attach-DCngg8cl.js → remote-attach-DGwVRmY5.js} +4 -4
  23. package/dist/{remote-extensions-BCAbQZTv.js → remote-extensions-CferWwM9.js} +3 -3
  24. package/dist/{remote-extensions-Cf3L-ZGG.js → remote-extensions-D8W2IgKH.js} +1 -1
  25. package/dist/{remote-language-server-UWQgpoh-.js → remote-language-server-By-rEosC.js} +5 -5
  26. package/dist/{remote-uninstall-CcDOcGyi.js → remote-uninstall-DgHnDAFH.js} +6 -6
  27. package/dist/{request-origin-Zcn_CjX4.js → request-origin-DDmfnytX.js} +38 -10
  28. package/dist/{review-app-launcher-tqSQPlbu.js → review-app-launcher-CG3TFZEx.js} +2 -2
  29. package/dist/{review-home-paths-Lc02WFuX.js → review-home-paths-D1JVitqN.js} +19 -17
  30. package/dist/{review-telemetry-BHWJsnoD.js → review-telemetry-Myzm7o6-.js} +5 -5
  31. package/dist/runtime-Bj2tD33R.js +27 -0
  32. package/dist/runtime.d.ts +1 -1
  33. package/dist/runtime.js +1 -1
  34. package/dist/{s3-77-qkEhs.js → s3-DtJFWC0G.js} +1 -1
  35. package/dist/{s3-config-DDWWMh5x.js → s3-config-BTO12tCC.js} +1 -1
  36. package/dist/{s3-config-0xuCh-F_.js → s3-config-CSy3fx8H.js} +1 -1
  37. package/dist/server/desktop-host.js +29 -76
  38. package/dist/{server-discovery-BVqrE1_-.js → server-discovery-Bwa_6Tsj.js} +3 -3
  39. package/dist/sharing/index.d.ts +486 -70
  40. package/dist/sharing/index.js +1 -1
  41. package/dist/{src-DsM3jO8G.js → src-BiWNbcF_.js} +89 -12
  42. package/dist/{src-BMPuxWv4.js → src-WPaQn75m.js} +208 -73
  43. package/dist/{stored-document-migration-CChxtFAy.js → stored-document-migration-dFbewRTG.js} +270 -269
  44. package/dist/{thread-state-5pYr6_zM.js → thread-state-Do5LX10I.js} +7 -2
  45. package/dist/{tool-failure-BFt2xj9i.js → tool-failure-C9J3r7KD.js} +10 -3
  46. package/instructions/authoring.md +3 -2
  47. package/package.json +4 -4
  48. package/src/agent-connect.ts +8 -2
  49. package/src/ask/agents.ts +51 -9
  50. package/src/ask/checkout-files.ts +2 -5
  51. package/src/ask/thread-state.ts +4 -0
  52. package/src/ask/thread.ts +16 -0
  53. package/src/ask/threads.ts +24 -3
  54. package/src/cli-runner.ts +4 -1
  55. package/src/diff-selection-migration.ts +48 -34
  56. package/src/heading-anchors.ts +42 -0
  57. package/src/lens-selection.ts +38 -2
  58. package/src/review-api/README.md +52 -13
  59. package/src/review-api/activity.ts +1 -4
  60. package/src/review-api/agent-cli.ts +9 -1
  61. package/src/review-api/anchor-quotes.ts +14 -7
  62. package/src/review-api/ask-history.ts +137 -51
  63. package/src/review-api/authoring-tools.ts +45 -30
  64. package/src/review-api/blocks/definition.ts +1 -1
  65. package/src/review-api/blocks/markdown.ts +2 -2
  66. package/src/review-api/board-order.ts +144 -0
  67. package/src/review-api/checkout-groups.ts +560 -0
  68. package/src/review-api/document-headings.ts +58 -27
  69. package/src/review-api/document-text.ts +31 -2
  70. package/src/review-api/document.ts +281 -34
  71. package/src/review-api/foreign-references.ts +328 -0
  72. package/src/review-api/github-provider.ts +51 -13
  73. package/src/review-api/http.ts +702 -123
  74. package/src/review-api/instructions.ts +0 -19
  75. package/src/review-api/link-preview.ts +113 -0
  76. package/src/review-api/local-data.ts +310 -46
  77. package/src/review-api/mcp.ts +3 -11
  78. package/src/review-api/onboarding.ts +1 -1
  79. package/src/review-api/profile.ts +37 -6
  80. package/src/review-api/public-tools.ts +24 -2
  81. package/src/review-api/pull-request.ts +82 -49
  82. package/src/review-api/request-origin.ts +10 -0
  83. package/src/review-api/review-progress.ts +61 -27
  84. package/src/review-api/scratchpad-plan-upgrade.ts +250 -0
  85. package/src/review-api/store-migrations.ts +357 -0
  86. package/src/review-api/store-schema.ts +38 -29
  87. package/src/review-api/store.ts +2144 -480
  88. package/src/review-api/workspaces.ts +52 -13
  89. package/src/review-api/worktree-events.ts +3 -5
  90. package/src/review-api/worktree-source.ts +3 -7
  91. package/src/review-api/worktree-structural.integration.ts +72 -0
  92. package/src/review-stack.ts +15 -23
  93. package/src/runtime.ts +4 -30
  94. package/src/server/desktop-host.ts +6 -1
  95. package/src/server/desktop-server.ts +1 -31
  96. package/src/server/headless-host.ts +0 -2
  97. package/src/server/hono-http.ts +2 -1
  98. package/src/server/legacy-tutorial-cleanup.ts +2 -3
  99. package/src/server/review-gateway-list.ts +3 -5
  100. package/src/server/review-gateway-memory.ts +1 -1
  101. package/src/server/review-lifecycle-telemetry.ts +2 -2
  102. package/src/server/review-server-core.ts +1 -5
  103. package/src/server/structural-comparisons.ts +47 -0
  104. package/src/sharing/export.ts +36 -7
  105. package/src/sharing/host.ts +34 -14
  106. package/src/sharing/import.ts +156 -24
  107. package/src/sharing/repository.ts +128 -20
  108. package/src/startup-trace.ts +3 -3
  109. package/src/stored-document-migration.ts +110 -19
  110. package/src/ui-telemetry-events.ts +25 -9
  111. package/dist/agent-cli-DeCrUC2s.js +0 -2
  112. package/dist/profile-Cz7L781D.js +0 -2
  113. package/dist/runtime-CKiWhGrf.js +0 -52
  114. package/instructions/scratchpad.md +0 -23
  115. package/src/review-preferences.ts +0 -88
@@ -1,3 +1,4 @@
1
+ import { type JsonValue, isJsonObject } from "@dev.fast/json";
1
2
  import { z } from "zod";
2
3
 
3
4
  import {
@@ -15,10 +16,7 @@ import { commandSchema } from "./store.js";
15
16
  /** The host publishes its input schemas, except where agents write less than
16
17
  * the host accepts (publishedEditSchema, publishedLensEditSchema) or name a
17
18
  * checkout by path (pathTargetSchema); adapters validate nothing. */
18
- export function authoringTools(
19
- scratchpadAvailable = false,
20
- traceEnabled = false,
21
- ) {
19
+ export function authoringTools(traceEnabled = false) {
22
20
  const id = z.string().min(1);
23
21
  const review = { reviewId: id };
24
22
 
@@ -31,19 +29,21 @@ export function authoringTools(
31
29
  ...trace.shape,
32
30
  kind: z.enum(["image", "trace"]),
33
31
  })
32
+ .omit({ repositoryId: true })
33
+ .extend({ repositoryPath: id })
34
34
  .partial()
35
- .required({ id: true, repositoryId: true, kind: true });
35
+ .required({ id: true, repositoryPath: true, kind: true });
36
36
 
37
37
  const descriptions = {
38
38
  create:
39
- 'Create a review of saved working files, immutable commits or a GitHub PR. Revisions are resolved on acceptance. A worktree target reviews the saved files in its checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base: the branch to compare against, by default the default branch of the repository. The diff starts at the merge base of base and HEAD, which follows rebases. Omitted commits base means source at head with no diff; supply the parent to review introduced changes. For a branch stacked on another, pass the branch below it as base; Desktop links reviews into a stack through their bases. For a GitHub PR, pullRequestUrl alone is enough: target and title become optional, and the host fetches the PR into a registered checkout of its repository and pins the current PR head and GitHub diff base, titled from the PR. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting. kind:"scratchpad" names the one scratchpad, which the host creates itself. The result carries review, the review as review_list shows it: its target with resolved commits, origin (its PR), repositoryName and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop.',
39
+ 'Create a whiteboard. target names its repository by the checkout\'s path, and what it shows: {kind:"project"} for a plan in that repository\'s project with no change; {kind:"worktree", base?} for the saved files in the checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base, by default the repository\'s default branch, from the merge base of base and HEAD, which follows rebases; {kind:"commits", head, base?} for immutable commits (omitted base means source at head with no diff; supply the parent to review introduced changes); {kind:"pullRequest", pullRequest} for a GitHub PR by URL or number, fetched into that checkout and pinned at the PR head and GitHub diff base. For a branch stacked on another, pass the branch below it as base; Desktop links whiteboards into a stack through their bases. Revisions are resolved on acceptance. With a PR and no title the PR\'s title is used. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting:false. The result carries review, as review_list shows it, with its target and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop. parentId and dependsOn set its place among the project\'s reviews, as review_set_parent and review_set_dependencies do.',
40
40
  set_target:
41
- "Move the review to a new target, such as the commits after a rebase or amend, preserving the document and component IDs. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. Omitted pullRequestUrl keeps the PR within the same repository; changing repositories clears it. Supply a URL to replace it or null to detach.",
41
+ "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories.",
42
42
  edit: [
43
- "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions (authoring or scratchpad).",
44
- "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {repositoryId, head, base?} say which commits anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
45
- "insert adds content: parentId nests it, afterId places it, and omitted placement appends (on the scratchpad, it lands at the top). update patches a component's own fields; null removes an optional one. A section's children and a diagram's units change through their own IDs; a call stack's frames change by replacing the call stack. move and remove take a targetId; removing a flow_node removes its edges.",
46
- "The host assigns short durable IDs. The result names the edited component and, for an insert or replace, its first-level children, and quotes the first and last line of each anchor the edit added or changed (the first 20; unquotedAnchors counts the rest): check they are the lines you meant. Accepted edits save at once.",
43
+ "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions.",
44
+ "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {head, base?} say which commits of the whiteboard's repository anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
45
+ "insert adds content: parentId nests it, afterId places it, and omitted placement appends. update patches a component's own fields; null removes an optional one. A section's children and a diagram's units change through their own IDs; a call stack's frames change by replacing the call stack. move and remove take a targetId; removing a flow_node removes its edges.",
46
+ "The host assigns short durable IDs. The result names the edited component and, for an insert or replace, its first-level children, and quotes the first and last line of each anchor the edit added or changed (the first 20; unquotedAnchors counts the rest): check they are the lines you meant. Accepted edits save at once. An edit to a block the user is editing is refused with an error: edit another block, or retry later.",
47
47
  "While a reader may be watching, write small and often: one paragraph per edit. Insert a new diagram whole. Change one already drawn a unit at a time (a flow_node, flow_edge or step, with parentId naming the diagram), and link each added flow_node to a node already drawn.",
48
48
  ].join("\n"),
49
49
  lens_edit:
@@ -56,7 +56,11 @@ export function authoringTools(
56
56
  lifecycle:
57
57
  "Move a review between active and trash. Trash retains the review for 30 days before automatic deletion. Moving it back to active restores it; moving it to trash again does not restart that period.",
58
58
  delete:
59
- "Permanently delete a review and its history. The review must be in Trash. This cannot be undone.",
59
+ "Permanently delete a review and its history. The review must be in Trash. This cannot be undone. Its children move up to top level, and dependencies on it are dropped.",
60
+ set_parent:
61
+ "Nest the review under another review of the same project, or make it top-level with parentId:null. Nesting is one level deep: the parent must be top-level, and a review with children can't become a child. The sidebar shows children under their parent. Saves no review version. Returns the review's relations: parentId, children in sibling order, and dependsOn.",
62
+ set_dependencies:
63
+ "Replace the list of reviews this one builds on: any reviews of the same project. Siblings list after what they depend on, directly or through other reviews; a PR stack is a chain. A list that would close a cycle is refused, naming the cycle. Saves no review version. Returns the review's relations.",
60
64
  };
61
65
 
62
66
  const tool = (
@@ -70,7 +74,7 @@ export function authoringTools(
70
74
  name: `review_${name === "restore" ? "restore_version" : name}`,
71
75
  description,
72
76
  inputSchema: {
73
- ...z.toJSONSchema(schema, { io: "input" }),
77
+ ...withoutRepositoryIds(z.toJSONSchema(schema, { io: "input" })),
74
78
  type: "object" as const,
75
79
  },
76
80
  method,
@@ -92,9 +96,6 @@ export function authoringTools(
92
96
  'Read Whiteboard\'s guidance before creating or editing a review. The default topic gives the authoring workflow; "file-lenses" covers Diff-view file lenses.' +
93
97
  (traceEnabled
94
98
  ? ' Call review_get_instructions({topic:"trace-archaeology"}) for why code exists, what an agent was thinking, or whether an agent solved this before.'
95
- : "") +
96
- (scratchpadAvailable
97
- ? ' When the user asks in conversation to be shown how code works or wants a diagram, without asking for a review, draw it on the scratchpad rather than answering only in chat: call review_get_instructions({topic:"scratchpad"}) first. A request for a review or to use Whiteboard means authoring a review with the default topic.'
98
99
  : ""),
99
100
  instructionsQuerySchema.partial(),
100
101
  "GET",
@@ -124,10 +125,12 @@ export function authoringTools(
124
125
  ...commandSchema.shape.operation.options.map((operation) => {
125
126
  const type = operation.shape.type.value;
126
127
 
127
- // Agents name a checkout by its path; /commands registers it.
128
+ // Hidden from agents: they name checkouts by path inside the target
129
+ // and give PRs as a target.
128
130
  const {
129
131
  type: _type,
130
132
  repositoryId: _repositoryId,
133
+ pullRequestUrl: _pullRequestUrl,
131
134
  ...fields
132
135
  }: Record<string, z.ZodType> = operation.shape;
133
136
 
@@ -137,12 +140,7 @@ export function authoringTools(
137
140
  z.strictObject({
138
141
  ...fields,
139
142
  ...(type === "create" && {
140
- target: pathTargetSchema.optional(),
141
- repositoryPath: id
142
- .optional()
143
- .describe(
144
- "Only with pullRequestUrl and no target: the local checkout to fetch the PR into. Default: the existing review's, else the first registered checkout with a remote for the PR's repository.",
145
- ),
143
+ target: pathTargetSchema,
146
144
  open: z.boolean().optional(),
147
145
  }),
148
146
  ...(type === "set_target" && { target: pathTargetSchema }),
@@ -196,13 +194,6 @@ export function authoringTools(
196
194
  "POST",
197
195
  "/workspace-cleanup",
198
196
  ),
199
- tool(
200
- "register_repository",
201
- "Register a local Git or jj repository and return its id, for pins on scratchpad blocks. session_create and session_set_target take the checkout's path instead.",
202
- z.strictObject({ path: id }),
203
- "POST",
204
- "/repositories",
205
- ),
206
197
  tool(
207
198
  "upload",
208
199
  'Retain an image or trace for use in a review. kind:"image" takes base64; kind:"trace" takes trace. Reusing an upload ID requires identical content; rejected uploads are not saved.',
@@ -212,3 +203,27 @@ export function authoringTools(
212
203
  ),
213
204
  ];
214
205
  }
206
+
207
+ /** Drops repositoryId from pins: the host fills in the board's own. */
208
+ function withoutRepositoryIds<T>(schema: T): T {
209
+ // SAFETY: a JSON round trip of a JSON Schema, dropping one property.
210
+ return JSON.parse(JSON.stringify(schema), (_key, value: JsonValue) => {
211
+ if (
212
+ !isJsonObject(value) ||
213
+ !isJsonObject(value.properties) ||
214
+ !("repositoryId" in value.properties) ||
215
+ !("head" in value.properties)
216
+ )
217
+ return value;
218
+
219
+ const { repositoryId: _repositoryId, ...properties } = value.properties;
220
+
221
+ return {
222
+ ...value,
223
+ properties,
224
+ ...(Array.isArray(value.required) && {
225
+ required: value.required.filter((name) => name !== "repositoryId"),
226
+ }),
227
+ };
228
+ }) as T;
229
+ }
@@ -13,7 +13,7 @@ export const identity = { id: text.optional() };
13
13
  * them reads its document's. */
14
14
  export const elementPins = sourcePinsSchema
15
15
  .optional()
16
- .describe("Pins for the anchors here; needed on the scratchpad.");
16
+ .describe("Pins for the anchors here.");
17
17
 
18
18
  /**
19
19
  * One block kind: the strict schema the store parses with, and the rules a
@@ -6,12 +6,12 @@ export const markdown = {
6
6
  type: "markdown",
7
7
  schema: defineBlock("markdown", {
8
8
  markdown: text.describe(
9
- "Safe Markdown. Repository file links must use [label](review-source:head/path#L10-L24) or review-source:base/path#L10-L24, with a repository-relative path and verified line numbers. Relative paths, absolute filesystem paths, and file/editor URLs are rejected. External links use https://, http://, or mailto:; document anchors use #heading.",
9
+ "Safe Markdown. Repository file links must use [label](whiteboard:source/head/path#L10-L24) or whiteboard:source/base/path#L10-L24, or whiteboard:source/<commit>/path#L10-L24 at any commit of the board's repository, with a repository-relative path and verified line numbers. Link another board as [label](whiteboard:board/<sessionId>) or one of its blocks as whiteboard:board/<sessionId>#<blockId>; both must exist. Relative paths, absolute filesystem paths, and file/editor URLs are rejected. External links use https://, http://, or mailto:; document anchors use #heading.",
10
10
  ),
11
11
  pins: sourcePinsSchema
12
12
  .optional()
13
13
  .describe(
14
- "Repository and commits this block's review-source links resolve against, instead of the document's pins.",
14
+ "Repository and commits this block's whiteboard:source/ links resolve against, instead of the document's pins.",
15
15
  ),
16
16
  }),
17
17
  } as const;
@@ -0,0 +1,144 @@
1
+ /** What sibling order reads about a board. */
2
+ export interface SiblingFacts {
3
+ id: string;
4
+ /** When it was made: an edit never moves it. */
5
+ createdAt: string;
6
+ /** Its pins: commits of repositoryId. Plans have none. */
7
+ repositoryId?: string;
8
+ base?: string;
9
+ head?: string;
10
+ /** A PR board's head branch, and its PR's base branch, when known. */
11
+ headBranch?: string;
12
+ baseBranch?: string;
13
+ }
14
+
15
+ /**
16
+ * Siblings in reading order. A comes after B when A reaches B through
17
+ * depends_on, directly or through boards outside the siblings. Between two
18
+ * siblings with no such chain either way, commits decide: A comes after B
19
+ * when A's base is, or descends from, B's head, or A's PR merges into B's
20
+ * branch. Remaining ties go newest made first, then by id, so an edit
21
+ * never reorders them.
22
+ */
23
+ export function orderSiblings(
24
+ siblings: readonly SiblingFacts[],
25
+ dependsOn: (id: string) => readonly string[],
26
+ isAncestor: (repositoryId: string, ancestor: string, of: string) => boolean,
27
+ ): string[] {
28
+ const reach = new Map(
29
+ siblings.map((board) => [board.id, reachable(board.id, dependsOn)]),
30
+ );
31
+
32
+ const after = (a: SiblingFacts, b: SiblingFacts) => {
33
+ if (reach.get(a.id)!.has(b.id)) return true;
34
+
35
+ if (reach.get(b.id)!.has(a.id)) return false;
36
+
37
+ return buildsOn(a, b, isAncestor) && !buildsOn(b, a, isAncestor);
38
+ };
39
+
40
+ const before = new Map(
41
+ siblings.map((a) => [
42
+ a.id,
43
+ new Set(siblings.flatMap((b) => (b !== a && after(a, b) ? [b.id] : []))),
44
+ ]),
45
+ );
46
+
47
+ const newestFirst = [...siblings].sort(
48
+ (a, b) =>
49
+ Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
50
+ a.id.localeCompare(b.id),
51
+ );
52
+
53
+ const placed = new Set<string>();
54
+ const order: string[] = [];
55
+
56
+ while (order.length < siblings.length) {
57
+ const remaining = newestFirst.filter((board) => !placed.has(board.id));
58
+
59
+ // Inferred order can disagree with itself; then age breaks the loop.
60
+ const next =
61
+ remaining.find((board) =>
62
+ [...before.get(board.id)!].every((id) => placed.has(id)),
63
+ ) ?? remaining[0]!;
64
+
65
+ placed.add(next.id);
66
+ order.push(next.id);
67
+ }
68
+
69
+ return order;
70
+ }
71
+
72
+ function reachable(
73
+ id: string,
74
+ dependsOn: (id: string) => readonly string[],
75
+ ): Set<string> {
76
+ const seen = new Set<string>();
77
+ const stack = [...dependsOn(id)];
78
+
79
+ while (stack.length) {
80
+ const next = stack.pop()!;
81
+
82
+ if (seen.has(next)) continue;
83
+ seen.add(next);
84
+ stack.push(...dependsOn(next));
85
+ }
86
+
87
+ return seen;
88
+ }
89
+
90
+ /** Whether a's change sits on top of b's. */
91
+ function buildsOn(
92
+ a: SiblingFacts,
93
+ b: SiblingFacts,
94
+ isAncestor: (repositoryId: string, ancestor: string, of: string) => boolean,
95
+ ): boolean {
96
+ if (a.repositoryId !== b.repositoryId) return false;
97
+
98
+ if (a.baseBranch && a.baseBranch === b.headBranch) return true;
99
+
100
+ if (!a.base || !b.head) return false;
101
+
102
+ if (a.base === b.head) return true;
103
+
104
+ // A board with no change of its own builds on nothing.
105
+ return a.base !== a.head && isAncestor(a.repositoryId!, b.head, a.base);
106
+ }
107
+
108
+ /**
109
+ * The path that adding `from → to` would close, from `from` back to itself,
110
+ * or undefined when it closes none.
111
+ */
112
+ export function dependencyCycle(
113
+ from: string,
114
+ to: string,
115
+ dependsOn: (id: string) => readonly string[],
116
+ ): string[] | undefined {
117
+ if (from === to) return [from, from];
118
+
119
+ const previous = new Map<string, string>();
120
+ const queue = [to];
121
+ const seen = new Set([to]);
122
+
123
+ while (queue.length) {
124
+ const current = queue.shift()!;
125
+
126
+ if (current === from) {
127
+ const path = [from];
128
+
129
+ for (let at = from; at !== to; at = previous.get(at)!)
130
+ path.unshift(previous.get(at)!);
131
+
132
+ return [from, ...path];
133
+ }
134
+
135
+ for (const next of dependsOn(current))
136
+ if (!seen.has(next)) {
137
+ seen.add(next);
138
+ previous.set(next, current);
139
+ queue.push(next);
140
+ }
141
+ }
142
+
143
+ return undefined;
144
+ }