@bevel-software/platform-shared 0.14.0 → 0.19.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 (73) hide show
  1. package/dist/auth/types.d.ts +12 -0
  2. package/dist/auth/types.d.ts.map +1 -1
  3. package/dist/git/pr.types.d.ts +75 -11
  4. package/dist/git/pr.types.d.ts.map +1 -1
  5. package/dist/git/types.d.ts +75 -2
  6. package/dist/git/types.d.ts.map +1 -1
  7. package/dist/index.d.ts +6 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +6 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/workflow/events.d.ts +39 -4
  12. package/dist/workflow/events.d.ts.map +1 -1
  13. package/dist/workflow/events.js.map +1 -1
  14. package/dist/workflow/interface.d.ts +137 -5
  15. package/dist/workflow/interface.d.ts.map +1 -1
  16. package/dist/workflow/types.d.ts +93 -0
  17. package/dist/workflow/types.d.ts.map +1 -1
  18. package/dist/workspace/access-verbs.d.ts +83 -0
  19. package/dist/workspace/access-verbs.d.ts.map +1 -0
  20. package/dist/workspace/access-verbs.js +110 -0
  21. package/dist/workspace/access-verbs.js.map +1 -0
  22. package/dist/workspace/agent-preamble.d.ts +33 -0
  23. package/dist/workspace/agent-preamble.d.ts.map +1 -0
  24. package/dist/workspace/agent-preamble.js +45 -0
  25. package/dist/workspace/agent-preamble.js.map +1 -0
  26. package/dist/workspace/entry-exists.d.ts +27 -0
  27. package/dist/workspace/entry-exists.d.ts.map +1 -0
  28. package/dist/workspace/entry-exists.js +33 -0
  29. package/dist/workspace/entry-exists.js.map +1 -0
  30. package/dist/workspace/filename.d.ts +15 -0
  31. package/dist/workspace/filename.d.ts.map +1 -1
  32. package/dist/workspace/filename.js +37 -3
  33. package/dist/workspace/filename.js.map +1 -1
  34. package/dist/workspace/frontmatter-carriers.d.ts +44 -0
  35. package/dist/workspace/frontmatter-carriers.d.ts.map +1 -0
  36. package/dist/workspace/frontmatter-carriers.js +52 -0
  37. package/dist/workspace/frontmatter-carriers.js.map +1 -0
  38. package/dist/workspace/frontmatter.d.ts +23 -4
  39. package/dist/workspace/frontmatter.d.ts.map +1 -1
  40. package/dist/workspace/frontmatter.js +59 -9
  41. package/dist/workspace/frontmatter.js.map +1 -1
  42. package/dist/workspace/kb-layout.d.ts +365 -19
  43. package/dist/workspace/kb-layout.d.ts.map +1 -1
  44. package/dist/workspace/kb-layout.js +619 -20
  45. package/dist/workspace/kb-layout.js.map +1 -1
  46. package/dist/workspace/placeholder.d.ts +21 -0
  47. package/dist/workspace/placeholder.d.ts.map +1 -0
  48. package/dist/workspace/placeholder.js +28 -0
  49. package/dist/workspace/placeholder.js.map +1 -0
  50. package/dist/workspace/platform-files.d.ts +105 -0
  51. package/dist/workspace/platform-files.d.ts.map +1 -0
  52. package/dist/workspace/platform-files.js +147 -0
  53. package/dist/workspace/platform-files.js.map +1 -0
  54. package/dist/workspace/types.d.ts +8 -0
  55. package/dist/workspace/types.d.ts.map +1 -1
  56. package/package.json +1 -1
  57. package/src/auth/types.ts +12 -0
  58. package/src/git/pr.types.ts +77 -11
  59. package/src/git/types.ts +81 -3
  60. package/src/index.ts +6 -0
  61. package/src/workflow/events.ts +40 -3
  62. package/src/workflow/interface.ts +162 -6
  63. package/src/workflow/types.ts +70 -0
  64. package/src/workspace/access-verbs.ts +124 -0
  65. package/src/workspace/agent-preamble.ts +47 -0
  66. package/src/workspace/entry-exists.ts +36 -0
  67. package/src/workspace/filename.ts +38 -3
  68. package/src/workspace/frontmatter-carriers.ts +57 -0
  69. package/src/workspace/frontmatter.ts +57 -8
  70. package/src/workspace/kb-layout.ts +660 -24
  71. package/src/workspace/placeholder.ts +29 -0
  72. package/src/workspace/platform-files.ts +188 -0
  73. package/src/workspace/types.ts +8 -0
package/src/git/types.ts CHANGED
@@ -68,9 +68,21 @@ export interface ValidationReport {
68
68
  rawOutput: string;
69
69
  }
70
70
 
71
+ /** What `IGitService.syncFromRemote` observed under its one hold of the clone. */
72
+ export interface RemoteSyncPullResult {
73
+ /** HEAD before the pull; null when the clone had no commits yet. */
74
+ before: string | null;
75
+ /** HEAD after the pull; null only when origin is still empty too. */
76
+ after: string | null;
77
+ /** Whether the working tree's CONTENT differs — tree ids, not commit ids. */
78
+ treeChanged: boolean;
79
+ /** Repo-relative paths whose content changed; empty unless `treeChanged`. */
80
+ changedPaths: string[];
81
+ }
82
+
71
83
  export interface IGitService {
72
84
  status(workspaceId: string): Promise<WorkingTreeStatus>;
73
- listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<BranchInfo[]>;
85
+ listBranches(workspaceId: string, opts?: { freshFetch?: boolean; strictFetch?: boolean }): Promise<BranchInfo[]>;
74
86
  createBranch(
75
87
  workspaceId: string,
76
88
  name: string,
@@ -129,7 +141,39 @@ export interface IGitService {
129
141
  },
130
142
  ): Promise<void>;
131
143
  fetch(workspaceId: string): Promise<void>;
132
- pull(workspaceId: string): Promise<void>;
144
+ /**
145
+ * `treeChanged` is whether the pull left the working tree holding different
146
+ * CONTENT than before the call — tree ids compared, not commit ids, so a
147
+ * pull that only moves HEAD across content-identical commits (an empty
148
+ * commit, a rebase that replays to the same result) reports false. Only the
149
+ * pull itself can answer that (it holds the workspace mutex across the
150
+ * rebase; any before/after probe a caller ran around it would race), and
151
+ * callers that announce "this tree changed" to the rest of the process need
152
+ * the distinction: an "already up to date" pull that broadcast anyway would
153
+ * drop every catalog cache and reload every attached browser for nothing.
154
+ */
155
+ pull(workspaceId: string): Promise<{ treeChanged: boolean }>;
156
+ /**
157
+ * The remote sync's pull, observed as ONE serialized operation: where HEAD
158
+ * was, the pull, where HEAD is, and which repo-relative paths changed
159
+ * (rename-aware: both ends). `pull` bracketed by separate reads would let a
160
+ * concurrent save land between them and be announced as the sync's own.
161
+ *
162
+ * Tolerant of an unborn HEAD (a clone of an empty upstream): `before` is
163
+ * null, and paths are diffed against the empty tree. Throws the typed
164
+ * pull-conflict error like `pull`, and a typed "remote branch gone" error
165
+ * when origin no longer has the branch — including for an unborn clone,
166
+ * once origin has any branch at all. The one exception: an unborn clone
167
+ * against an origin with NO branches (a fresh deployment nobody has pushed
168
+ * to) resolves to `after: null`, since there is nothing to sync and nothing
169
+ * stale.
170
+ */
171
+ syncFromRemote(workspaceId: string): Promise<RemoteSyncPullResult>;
172
+ /**
173
+ * Whether origin still has `branch` right now (`ls-remote`). Used to
174
+ * revalidate that a clone is still stale before it is retired.
175
+ */
176
+ remoteBranchExists(workspaceId: string, branch: string): Promise<boolean>;
133
177
  diffStat(workspaceId: string, base?: string): Promise<string[]>;
134
178
  /**
135
179
  * Paths in the working tree that the next commit would include — the set
@@ -205,6 +249,40 @@ export interface IGitService {
205
249
  * Just the repo-relative paths a change request touches (three-dot diff,
206
250
  * no statuses, no patches): the cheap form behind change-request list
207
251
  * summaries and owner routing.
252
+ *
253
+ * `fetch: false` skips the per-request fetch of the two refs — for a caller
254
+ * that has just refreshed the whole clone's remote-tracking refs in one
255
+ * round trip, which is what a LIST does rather than paying one fetch per
256
+ * request. Pass it only when that is true; otherwise the diff can describe
257
+ * a stale head. It is a skip, not a promise: a branch the clone does not
258
+ * have yet is fetched anyway, since there is nothing to diff without it —
259
+ * so a list's first sight of a new request still costs one round trip.
260
+ */
261
+ changedPathsForPr(
262
+ workspaceId: string,
263
+ baseBranch: string,
264
+ headBranch: string,
265
+ opts?: { fetch?: boolean },
266
+ ): Promise<string[]>;
267
+
268
+ /**
269
+ * A change request's fork point (merge base of the two resolved commits)
270
+ * and whether the target has commits the proposal does not contain. No
271
+ * fetch: `at` is what `resolvePrShas` just returned.
272
+ */
273
+ forkPointForPr(
274
+ workspaceId: string,
275
+ at: { baseSha: string; headSha: string },
276
+ ): Promise<{ mergeBaseSha: string | null; behind: boolean }>;
277
+
278
+ /**
279
+ * A file's content at a change request's fork point — a commit that must
280
+ * be on `baseBranch`'s history. `null` when the path did not exist there.
208
281
  */
209
- changedPathsForPr(workspaceId: string, baseBranch: string, headBranch: string): Promise<string[]>;
282
+ readFileAtForkPoint(
283
+ workspaceId: string,
284
+ baseBranch: string,
285
+ sha: string,
286
+ relativePath: string,
287
+ ): Promise<string | null>;
210
288
  }
package/src/index.ts CHANGED
@@ -6,10 +6,16 @@ export * from './chat/types.js';
6
6
 
7
7
  // Workspace
8
8
  export * from './workspace/types.js';
9
+ export * from './workspace/agent-preamble.js';
9
10
  export * from './workspace/filename.js';
11
+ export * from './workspace/entry-exists.js';
10
12
  export * from './workspace/kb-layout.js';
11
13
  export * from './workspace/join-request.js';
12
14
  export * from './workspace/frontmatter.js';
15
+ export * from './workspace/placeholder.js';
16
+ export * from './workspace/frontmatter-carriers.js';
17
+ export * from './workspace/platform-files.js';
18
+ export * from './workspace/access-verbs.js';
13
19
 
14
20
  // Git
15
21
  export * from './git/types.js';
@@ -132,6 +132,13 @@ export interface GitSyncFailedEvent {
132
132
  branch: string;
133
133
  /** Sanitised git error — safe to display, tokens already redacted. */
134
134
  reason: string;
135
+ /**
136
+ * Present when the failure is a REMOTE-SYNC CONFLICT (`POST /api/sync`
137
+ * found Hexis-side commits that contradict what landed on the host): the
138
+ * repo-relative files a person has to reconcile. Unlike a failing push,
139
+ * this is the author's to act on, so the banner offers to open them.
140
+ */
141
+ conflictedPaths?: string[];
135
142
  }
136
143
 
137
144
  /**
@@ -214,6 +221,28 @@ export interface ChangeRequestRejectedEvent {
214
221
  number: number;
215
222
  }
216
223
 
224
+ /**
225
+ * An apply did NOT land, announced to EVERY session — the counterpart of the
226
+ * user-scoped `change-request-merge-failed` below, which only the clicker gets.
227
+ *
228
+ * The request stays open, so everyone who can see it (its author waiting on
229
+ * the verdict, another owner, another admin) needs to learn that the attempt
230
+ * failed and why. The reason is deliberately NOT carried here: it is persisted
231
+ * on the request (`PullRequestSummary.lastApplyFailure`) and read back through
232
+ * the list and detail endpoints the viewer already uses, so the broadcast puts
233
+ * no error text in front of every session, and a session that missed the event
234
+ * reads the same answer on its next fetch.
235
+ *
236
+ * Also sent when a recorded refusal is CLEARED because a change made it
237
+ * obsolete (an approval for a gate refusal, a moved source head for any):
238
+ * either way the request's `lastApplyFailure` changed, and every viewer
239
+ * re-reads it.
240
+ */
241
+ export interface ChangeRequestApplyFailedEvent {
242
+ kind: 'change-request-apply-failed';
243
+ number: number;
244
+ }
245
+
217
246
  /**
218
247
  * A merge the caller triggered did NOT land — either the gate refused it, the
219
248
  * branch needs conflict resolution, or `gh pr merge` failed. The merge route
@@ -221,9 +250,10 @@ export interface ChangeRequestRejectedEvent {
221
250
  * gateway timeout), so this is how the failure reaches the UI that kicked it
222
251
  * off. Success travels on `change-request-merged` instead.
223
252
  *
224
- * User-scoped (`forUserId`, no `workspaceId`) — a failed merge changes no
225
- * shared state; only the user who clicked needs the reason, so we don't
226
- * broadcast the error string to every session.
253
+ * User-scoped (`forUserId`, no `workspaceId`) — the clicker's immediate
254
+ * answer, which also routes a conflict into the resolution flow. Everyone else
255
+ * learns of the failure from `change-request-apply-failed`, which carries no
256
+ * error string; the reason itself is persisted on the request.
227
257
  */
228
258
  export interface ChangeRequestMergeFailedEvent {
229
259
  kind: 'change-request-merge-failed';
@@ -236,6 +266,12 @@ export interface ChangeRequestMergeFailedEvent {
236
266
  * The UI routes to the agent resolution flow instead of showing an error.
237
267
  */
238
268
  conflicts: boolean;
269
+ /**
270
+ * When the attempt failed (ISO) — the same instant persisted as
271
+ * `lastApplyFailure.at` when the refusal is recorded, so the clicker's tab
272
+ * can tell its own refusal from a later one somebody else's attempt made.
273
+ */
274
+ at?: string;
239
275
  }
240
276
 
241
277
  /**
@@ -289,6 +325,7 @@ export type WorkflowEventPayload =
289
325
  | ChangeRequestMergedEvent
290
326
  | ChangeRequestRejectedEvent
291
327
  | ChangeRequestMergeFailedEvent
328
+ | ChangeRequestApplyFailedEvent
292
329
  | ApprovalChangedEvent
293
330
  | HeartbeatEvent
294
331
  | ResyncEvent;
@@ -18,9 +18,11 @@
18
18
  */
19
19
 
20
20
  import type { AuthUser } from '../auth/types.js';
21
+ import type { ChangeRequestApplyFailureKind } from '../git/pr.types.js';
21
22
  import type {
22
23
  AcquireLockResult,
23
24
  Branch,
25
+ BranchSyncOutcome,
24
26
  BranchWorkspaceStatus,
25
27
  CancelChangeRequestResult,
26
28
  Change,
@@ -32,6 +34,9 @@ import type {
32
34
  ChangedFile,
33
35
  FileApproval,
34
36
  FileLock,
37
+ FolderChangeRequest,
38
+ FolderChangeRequestRemoval,
39
+ MergeBranchOutcome,
35
40
  MergeChangeRequestOutcome,
36
41
  OpenChangeRequestInput,
37
42
  PostChangeRequestCommentInput,
@@ -40,11 +45,18 @@ import type {
40
45
  export interface IWorkflowService {
41
46
  // ── Branches ──────────────────────────────────────────────────────────────
42
47
 
43
- listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]>;
48
+ /**
49
+ * `freshFetch` bypasses the fetch TTL; `strictFetch` makes a failed fetch
50
+ * throw instead of serving the stale refs — for a caller that uses the
51
+ * list to prove a branch ABSENT (a stale list proves nothing).
52
+ */
53
+ listBranches(workspaceId: string, opts?: { freshFetch?: boolean; strictFetch?: boolean }): Promise<Branch[]>;
44
54
  /**
45
55
  * Create a new unprotected branch. Protected branches cannot be created
46
56
  * via the workflow — the protected set is bootstrapped from the KB repo's
47
- * initial state and never grown at runtime.
57
+ * initial state and never grown at runtime. Holds the branch-lifecycle
58
+ * lock for `name`, so creation is serialised with the branch's deletion
59
+ * and with the retirement of a stale clone under that name.
48
60
  */
49
61
  createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch>;
50
62
  /**
@@ -101,6 +113,31 @@ export interface IWorkflowService {
101
113
  * `user` when provided (falls back to the recovery bot).
102
114
  */
103
115
  updateFromRemote(workspaceId: string, user?: AuthUser): Promise<void>;
116
+ /**
117
+ * The remote-sync step for ONE branch's clone, driven by a git host's
118
+ * webhook or a pipeline rather than by a person's save. Pulls with the same
119
+ * rebase-and-autostash `updateFromRemote` uses and, when HEAD moved,
120
+ * announces the new tree (`fs-tree-changed`, one `file-changed` per path)
121
+ * so open browsers, agents and the catalogues see it at once.
122
+ *
123
+ * On a conflict it does what `updateFromRemote` does — the divergence goes
124
+ * to the same background recovery ladder (attributed to the recovery bot,
125
+ * since no person triggered the sync) — and, because that is not resolved
126
+ * at request time, reports it as a `conflict` outcome naming the files, so
127
+ * the caller can fail and a person can step in if recovery does not clear
128
+ * it. Never throws for a per-branch failure; every failure is an outcome.
129
+ */
130
+ syncWorkspaceFromRemote(workspaceId: string): Promise<BranchSyncOutcome>;
131
+ /**
132
+ * Retire the clone of a branch the host has deleted (a `remote-gone`
133
+ * outcome). Runs under the branch-lifecycle lock — the one `createBranch`
134
+ * and `deleteBranch` hold, so no Hexis operation can recreate the branch
135
+ * while the clone is examined and removed — revalidates against origin
136
+ * first, and backs off while a clone of the branch is being bootstrapped.
137
+ * A branch recreated in the meantime therefore keeps its clone. Returns
138
+ * whether the clone was removed.
139
+ */
140
+ retireRemoteGoneClone(workspaceId: string): Promise<boolean>;
104
141
  /**
105
142
  * Hard-reset the workspace's checked-out branch to `origin/<branch>` (fetch
106
143
  * first), discarding any local divergence. Break-glass primitive for
@@ -189,9 +226,38 @@ export interface IWorkflowService {
189
226
  path: string,
190
227
  sha: string,
191
228
  ): Promise<{ baseline: string | null; current: string | null }>;
229
+ /**
230
+ * One file as it stood at a change request's fork point (`sha`, which must
231
+ * lie on the target branch's history) — the "before" side of the request
232
+ * dialog's diff. `null` when the path did not exist there. Access is the
233
+ * caller's to check.
234
+ */
235
+ fileAtForkPoint(
236
+ workspaceId: string,
237
+ baseBranch: string,
238
+ sha: string,
239
+ path: string,
240
+ ): Promise<string | null>;
241
+ /**
242
+ * A change request's current fork point: the merge base of the freshly
243
+ * fetched target and source branches, or `null` when they share no history.
244
+ */
245
+ changeRequestForkPoint(
246
+ workspaceId: string,
247
+ baseBranch: string,
248
+ headBranch: string,
249
+ ): Promise<string | null>;
192
250
 
193
251
  // ── File locks (new — currently NotImplementedWorkflowError) ──────────────
194
252
 
253
+ // Every lock method below coordinates on ONE canonical identity per file:
254
+ // `x/a.md`, `./x/a.md` and `x//a.md` name the same lock, so a lock acquired
255
+ // under any of them is contended, heartbeated, checkpointed, released and
256
+ // read under any other. A path that escapes the workspace, or that only
257
+ // becomes relative by being laundered, is refused with the same status the
258
+ // file verbs answer for that input: 400 for an unusable path, 403 for one
259
+ // that resolves outside the workspace.
260
+
195
261
  /**
196
262
  * Try to acquire an edit lock on `(branch, path)` for the caller. Returns
197
263
  * `{ acquired: true, lock }` on success; `{ acquired: false, lock }` if
@@ -213,13 +279,37 @@ export interface IWorkflowService {
213
279
  * `releaseLockUntouched`) — `releaseLock` rejects rather than ever enqueue
214
280
  * a commit for a hold that was never allowed to write. Internal callers
215
281
  * only; never plumbed from a route.
282
+ *
283
+ * Every non-coordination acquire also passes the READ-BEFORE-WRITE gate, on
284
+ * every branch: the caller must be able to read the path (for a new path,
285
+ * where it lands), or the path must start a new folder directly under one
286
+ * of the three roots (knowledge, skills, plugins). Nothing is created,
287
+ * changed or removed where its author cannot see it, whatever write rules
288
+ * say; a refusal is an `AccessDeniedError` whose `access.unreadable` names
289
+ * the place. A write that passed only as a platform-file restore is not
290
+ * asked — that rescue exists for a destination whose rules deny the admin
291
+ * making it.
292
+ *
293
+ * `opts.platformRestore` CLAIMS that this acquire is the destination side of
294
+ * an admin putting a misplaced platform file back (`access.md`, `roles.yaml`,
295
+ * `.bevelignore`, the agent guide), and names the move's `source` — the path the
296
+ * file is coming FROM, in the same spelling as `path`.
297
+ *
298
+ * It is a claim, not an authorisation. The implementation re-asks both
299
+ * halves of it: that source→path is a restore at all (a misplaced copy
300
+ * going back under its own name, never the root's own copy coming out —
301
+ * `isPlatformRestoreShape`), and that the access module
302
+ * (`canRestorePlatformFile`) lets this caller land this exact path. Only
303
+ * both yeses let the acquire past the write gate, so a caller that omits or
304
+ * fakes the source gains nothing: the gate never takes the route's word for
305
+ * which move this is.
216
306
  */
217
307
  acquireLock(
218
308
  workspaceId: string,
219
309
  branch: string,
220
310
  path: string,
221
311
  user: AuthUser,
222
- opts?: { coordination?: boolean },
312
+ opts?: { coordination?: boolean; platformRestore?: { source: string } },
223
313
  ): Promise<AcquireLockResult>;
224
314
  /** Heartbeat to keep an acquired lock alive past its current TTL. */
225
315
  heartbeatLock(workspaceId: string, branch: string, path: string, user: AuthUser): Promise<FileLock>;
@@ -361,9 +451,12 @@ export interface IWorkflowService {
361
451
  ): Promise<ChangeRequestDetail>;
362
452
 
363
453
  /**
364
- * Re-run `targetBranch → sourceBranch` merge on an existing change request.
365
- * Used when the target has advanced since the change request was opened.
366
- * Throws `NotImplementedWorkflowError` until the backing merge path lands.
454
+ * Re-run `targetBranch → sourceBranch` merge on an existing change request
455
+ * and push it — the request dialog's Update, offered when the target has
456
+ * advanced since the request was opened. Only the request's author or
457
+ * someone who may apply it (`viewerCanUpdate`) may run it (403 otherwise).
458
+ * A conflicting merge is aborted, leaving the branch exactly as it was, and
459
+ * surfaces as `ChangeRequestConflictsError` (409).
367
460
  */
368
461
  updateFromTarget(workspaceId: string, user: AuthUser, number: number): Promise<ChangeRequestDetail>;
369
462
 
@@ -421,6 +514,22 @@ export interface IWorkflowService {
421
514
  */
422
515
  closeEmptyChangeRequest(number: number, user: AuthUser): Promise<boolean>;
423
516
 
517
+ /**
518
+ * The open change requests proposing files under a KB-repo-relative
519
+ * folder, each with whether the caller may take those files out of it
520
+ * (their own request, they are an admin, or they may write every file it
521
+ * proposes under the folder).
522
+ */
523
+ changeRequestsUnderFolder(folder: string, user: AuthUser): Promise<FolderChangeRequest[]>;
524
+
525
+ /**
526
+ * Take every file under the folder out of every open change request
527
+ * proposing one; a request left empty is withdrawn. All or nothing: refused
528
+ * (403), with nothing touched, when the caller may not act on even one of
529
+ * them.
530
+ */
531
+ removeFolderFromChangeRequests(folder: string, user: AuthUser): Promise<FolderChangeRequestRemoval[]>;
532
+
424
533
  /**
425
534
  * Close every open change request either of whose branches no longer exists
426
535
  * — source or target, since a proposal needs both ends. Such a request
@@ -477,4 +586,51 @@ export interface IWorkflowService {
477
586
  workspaceId: string,
478
587
  opts?: { bypass?: boolean },
479
588
  ): Promise<MergeChangeRequestOutcome>;
589
+
590
+ /** Start an apply attempt on a request; the token scopes `recordApplyFailure`. */
591
+ beginApplyAttempt(number: number): number;
592
+
593
+ /** End an attempt `beginApplyAttempt` started, whatever its outcome. */
594
+ endApplyAttempt(number: number, attempt: number): void;
595
+
596
+ /**
597
+ * Persist why an apply did not land on the (still open) request, and
598
+ * announce `change-request-apply-failed` to every session so the author and
599
+ * other viewers re-read it — not only the user who clicked. Resolves false,
600
+ * recording and announcing nothing, when `attempt` is no longer the latest
601
+ * or the request is no longer open.
602
+ */
603
+ recordApplyFailure(
604
+ number: number,
605
+ failure: { reason: string; kind: ChangeRequestApplyFailureKind; at?: Date },
606
+ user: AuthUser,
607
+ attempt: number,
608
+ ): Promise<boolean>;
609
+
610
+ /**
611
+ * Merge `sourceBranch` into `targetBranch` directly, authored as `user`,
612
+ * and publish the target. The agent path for merging branches — it never
613
+ * lands a change request:
614
+ *
615
+ * - refused (`OpenChangeRequestBlocksMergeError`, naming the request) when
616
+ * a change request from `sourceBranch` into `targetBranch` is open; a
617
+ * person merges that one in the app. The reverse direction — the target
618
+ * into the source, the sync that keeps a draft current — is allowed.
619
+ * - refused (`WorkflowDomainError`, `kind: 'protected-merge-target'`,
620
+ * status 403, listing the denied paths) when `targetBranch` is protected
621
+ * and `user` could not commit every file the merge changes directly to
622
+ * it. Decided against the target commit the merge is built on, not a
623
+ * workspace `HEAD` that may be behind it.
624
+ * - refused (`WorkflowDomainError`, `kind: 'protected-merge-changes-roles'`,
625
+ * status 403) when `targetBranch` is protected and the merge would
626
+ * change its `roles.yaml` — whoever the caller is. Roles never change
627
+ * through a merge; a change request's merge restores the target's copy
628
+ * first, and this path refuses instead. Roles are changed in the app.
629
+ * - refused (`WorkflowDomainError`, `kind: 'merge-target-busy'`, status
630
+ * 409) when `targetBranch`'s workspace still holds unshared edits: the
631
+ * merge resets it to the published tip, which would discard them.
632
+ *
633
+ * Conflicts write nothing and come back as `conflicts-need-resolution`.
634
+ */
635
+ mergeBranch(user: AuthUser, sourceBranch: string, targetBranch: string): Promise<MergeBranchOutcome>;
480
636
  }
@@ -133,6 +133,40 @@ export type CancelChangeRequestResult = CancelPrResult;
133
133
  export type ChangeRequestComment = PrReviewComment;
134
134
  export type PostChangeRequestCommentInput = PostPrCommentInput;
135
135
 
136
+ /**
137
+ * One open change request proposing files under a folder, as a folder delete
138
+ * lists it. `mayRemove`: the caller may take those files out of it (their own,
139
+ * they are an admin, or they may write every file it proposes under the
140
+ * folder); a refusal always says why.
141
+ */
142
+ export type FolderChangeRequest = {
143
+ number: number;
144
+ title: string;
145
+ authorName: string | null;
146
+ /** The caller authored it. */
147
+ mine: boolean;
148
+ /** KB-repo-relative paths it proposes under the folder. */
149
+ paths: string[];
150
+ } & ({ mayRemove: true } | { mayRemove: false; reason: string });
151
+
152
+ /** What removing a folder's files did to one change request. */
153
+ export interface FolderChangeRequestRemoval {
154
+ number: number;
155
+ removedPaths: string[];
156
+ /** The request proposed nothing else, so it was withdrawn. */
157
+ withdrawn: boolean;
158
+ /**
159
+ * Files under the folder it still proposes: added while the removal ran,
160
+ * so never judged, and left alone.
161
+ */
162
+ stillProposed: string[];
163
+ /**
164
+ * It proposes nothing now but was kept open, because a save to its branch
165
+ * was still landing — withdrawing would have deleted that save.
166
+ */
167
+ keptForSaves: boolean;
168
+ }
169
+
136
170
  /**
137
171
  * Input for opening a new change request. The workflow auto-merges
138
172
  * `targetBranch` into `sourceBranch` as part of opening, so callers don't
@@ -163,3 +197,39 @@ export interface OpenChangeRequestInput {
163
197
  export type MergeChangeRequestOutcome =
164
198
  | { kind: 'merged'; result: MergeChangeRequestResult }
165
199
  | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
200
+
201
+ /**
202
+ * What a direct branch-to-branch merge (`mergeBranch`) did: landed as `sha`
203
+ * on the target (the target's own tip when it already contained the source),
204
+ * or stopped on conflicts, nothing written.
205
+ */
206
+ export type MergeBranchOutcome =
207
+ | { kind: 'merged'; sha: string }
208
+ | { kind: 'conflicts-need-resolution'; conflictedPaths: string[] };
209
+
210
+ /**
211
+ * What a remote sync did to one branch's clone. One entry per branch in the
212
+ * `POST /api/sync` response, so a pipeline can read exactly which branch
213
+ * moved, which was already current, and which needs a person.
214
+ *
215
+ * - `updated` — the clone moved from `from` (null when it had no commits
216
+ * yet) to `to`.
217
+ * - `up-to-date` — origin had nothing new; `to` is the unchanged HEAD.
218
+ * - `not-cloned` — Hexis has no clone of this branch, so there is nothing
219
+ * to refresh (the first visit clones it fresh).
220
+ * - `remote-gone` — the branch no longer exists on the host; the stale clone
221
+ * is removed. Not a failure: a deleted branch has nothing
222
+ * to sync.
223
+ * - `conflict` — Hexis-side commits contradict what landed on the host.
224
+ * The clone is untouched; `error` is the message to show
225
+ * and `conflictedPaths` the files a person must reconcile.
226
+ * - `error` — the pull failed for another reason (origin unreachable,
227
+ * credential refused); `error` is the sanitised message.
228
+ */
229
+ export type BranchSyncOutcome =
230
+ | { branch: string; outcome: 'updated'; from: string | null; to: string }
231
+ | { branch: string; outcome: 'up-to-date'; to: string }
232
+ | { branch: string; outcome: 'not-cloned' }
233
+ | { branch: string; outcome: 'remote-gone' }
234
+ | { branch: string; outcome: 'conflict'; conflictedPaths: string[]; error: string }
235
+ | { branch: string; outcome: 'error'; error: string };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The access verbs and the ONE dependency graph between them.
3
+ *
4
+ * Shared so that everything that folds verbs — the resolver in core-backend,
5
+ * the eligible lists it serves, and the share dialog in core-frontend that
6
+ * renders and edits them — reads the same table. A second fold written out by
7
+ * hand somewhere else is how the sheet comes to show "Owner" for someone the
8
+ * resolver refuses, or writes a `read:` line under an `owner:` it just granted.
9
+ *
10
+ * `read` controls who may VIEW a path. It is default-deny: a path with no
11
+ * effective grant of any verb is not readable. `write` is editing (and, with
12
+ * it, approval rights), `download` is saving a copy, `owner` is the contact
13
+ * point for the node and the right to manage its access.
14
+ */
15
+
16
+ /**
17
+ * Verbs an `access.md` frontmatter can grant, each a list of principals
18
+ * optionally prefixed with `deny `. `AccessFile.entries` in the backend is
19
+ * statically keyed on this union; keep `Verb` and `KNOWN_VERBS` in lockstep.
20
+ */
21
+ export const KNOWN_VERBS = ['read', 'write', 'download', 'owner'] as const;
22
+ export type Verb = (typeof KNOWN_VERBS)[number];
23
+
24
+ /**
25
+ * THE DEPENDENCY GRAPH — what holding each verb presupposes, declared once.
26
+ * `owner` presupposes `write` and `download`; `write` and `download` each
27
+ * presuppose `read`; `read` presupposes nothing. Everything about how verbs
28
+ * fold into one another is DERIVED from this table, in both directions:
29
+ *
30
+ * - a GRANT confers, downwards, every verb the granted one presupposes
31
+ * (`sourceVerbsFor`, `effectiveVerbs`): an owner may edit, save and open;
32
+ * an editor may open; someone trusted with a copy may open it;
33
+ * - a DENIAL strips, upwards, every verb that presupposes the denied one
34
+ * (`requiredVerbsFor`): nobody owns what they may not edit, and nobody
35
+ * edits or saves what they may not open.
36
+ *
37
+ * Neither converse is implied: a `deny write` says nothing about `read`, and a
38
+ * `read` grant confers no `write`. `write` and `download` are independent of
39
+ * each other. Add a verb, or change what one presupposes, HERE and nowhere
40
+ * else; every list and fold below follows.
41
+ */
42
+ export const VERB_REQUIRES: Readonly<Record<Verb, readonly Verb[]>> = {
43
+ read: [],
44
+ write: ['read'],
45
+ download: ['read'],
46
+ owner: ['write', 'download'],
47
+ };
48
+
49
+ /** `verb` and everything it presupposes, transitively. */
50
+ function presupposedBy(verb: Verb): ReadonlySet<Verb> {
51
+ const out = new Set<Verb>();
52
+ const visit = (v: Verb) => {
53
+ if (out.has(v)) return;
54
+ out.add(v);
55
+ for (const dep of VERB_REQUIRES[v]) visit(dep);
56
+ };
57
+ visit(verb);
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Verbs whose GRANT confers `verb`, target verb first: `verb` itself, then
63
+ * every verb that presupposes it, in `KNOWN_VERBS` order.
64
+ */
65
+ export function sourceVerbsFor(verb: Verb): Verb[] {
66
+ return [verb, ...KNOWN_VERBS.filter((v) => v !== verb && presupposedBy(v).has(verb))];
67
+ }
68
+
69
+ /**
70
+ * Verbs whose DENIAL strips `verb`, target verb first: `verb` itself, then
71
+ * every verb it presupposes, in `KNOWN_VERBS` order.
72
+ */
73
+ export function requiredVerbsFor(verb: Verb): Verb[] {
74
+ const needs = presupposedBy(verb);
75
+ return [verb, ...KNOWN_VERBS.filter((v) => v !== verb && needs.has(v))];
76
+ }
77
+
78
+ /**
79
+ * The verbs in the order a change to a principal's set has to be APPLIED:
80
+ * broadest first, so every verb comes before the verbs it presupposes (ties in
81
+ * `KNOWN_VERBS` order). Lowering must start at the top — denying `write` while
82
+ * an `owner:` grant still stands in the same file is refused as ineffective,
83
+ * since owner confers write; stripping owner first removes what was conferring
84
+ * it. Raising reads the same order for the opposite reason: granting `owner`
85
+ * first satisfies the rest in one line.
86
+ */
87
+ export const VERBS_BROADEST_FIRST: readonly Verb[] = [...KNOWN_VERBS].sort(
88
+ (a, b) =>
89
+ presupposedBy(b).size - presupposedBy(a).size || KNOWN_VERBS.indexOf(a) - KNOWN_VERBS.indexOf(b),
90
+ );
91
+
92
+ /** One flag per verb: what a principal holds, or what a form has ticked. */
93
+ export type VerbSet = Record<Verb, boolean>;
94
+ /** A partial set — flags left out read as not held. */
95
+ export type VerbFlags = Partial<Record<Verb, boolean>>;
96
+
97
+ /**
98
+ * The grant fold, as a whole set: what a principal EFFECTIVELY holds given the
99
+ * verbs they are granted. Each verb is held when it, or any verb that
100
+ * presupposes it, is in `held`. Idempotent on an already-folded set.
101
+ */
102
+ export function effectiveVerbs(held: VerbFlags): VerbSet {
103
+ const out = {} as VerbSet;
104
+ for (const verb of KNOWN_VERBS) out[verb] = sourceVerbsFor(verb).some((w) => held[w] === true);
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * True when some OTHER verb in `held` confers `verb` — it is implied, not
110
+ * chosen, which is what a form renders as checked-and-disabled.
111
+ */
112
+ export function conferredByOthers(held: VerbFlags, verb: Verb): boolean {
113
+ return sourceVerbsFor(verb).some((w) => w !== verb && held[w] === true);
114
+ }
115
+
116
+ /**
117
+ * The fewest grant lines that produce `held`: every held verb that no other
118
+ * held verb confers, broadest first. `{ owner }` is one line; `{ write, read,
119
+ * download }` is `write` and `download` (read rides on either); `{ read,
120
+ * download }` is `download` alone. A flag left out is not granted.
121
+ */
122
+ export function minimalGrantVerbs(held: VerbFlags): Verb[] {
123
+ return VERBS_BROADEST_FIRST.filter((verb) => held[verb] === true && !conferredByOthers(held, verb));
124
+ }