@bevel-software/platform-shared 0.1.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 (77) hide show
  1. package/LICENSE +202 -0
  2. package/dist/auth/types.d.ts +15 -0
  3. package/dist/auth/types.d.ts.map +1 -0
  4. package/dist/auth/types.js +2 -0
  5. package/dist/auth/types.js.map +1 -0
  6. package/dist/chat/types.d.ts +39 -0
  7. package/dist/chat/types.d.ts.map +1 -0
  8. package/dist/chat/types.js +13 -0
  9. package/dist/chat/types.js.map +1 -0
  10. package/dist/git/branchAuthor.d.ts +28 -0
  11. package/dist/git/branchAuthor.d.ts.map +1 -0
  12. package/dist/git/branchAuthor.js +45 -0
  13. package/dist/git/branchAuthor.js.map +1 -0
  14. package/dist/git/pr.types.d.ts +264 -0
  15. package/dist/git/pr.types.d.ts.map +1 -0
  16. package/dist/git/pr.types.js +2 -0
  17. package/dist/git/pr.types.js.map +1 -0
  18. package/dist/git/protected.d.ts +52 -0
  19. package/dist/git/protected.d.ts.map +1 -0
  20. package/dist/git/protected.js +92 -0
  21. package/dist/git/protected.js.map +1 -0
  22. package/dist/git/review.types.d.ts +49 -0
  23. package/dist/git/review.types.d.ts.map +1 -0
  24. package/dist/git/review.types.js +2 -0
  25. package/dist/git/review.types.js.map +1 -0
  26. package/dist/git/types.d.ts +107 -0
  27. package/dist/git/types.d.ts.map +1 -0
  28. package/dist/git/types.js +2 -0
  29. package/dist/git/types.js.map +1 -0
  30. package/dist/index.d.ts +15 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +20 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/workflow/events.d.ts +218 -0
  35. package/dist/workflow/events.d.ts.map +1 -0
  36. package/dist/workflow/events.js +48 -0
  37. package/dist/workflow/events.js.map +1 -0
  38. package/dist/workflow/interface.d.ts +233 -0
  39. package/dist/workflow/interface.d.ts.map +1 -0
  40. package/dist/workflow/interface.js +20 -0
  41. package/dist/workflow/interface.js.map +1 -0
  42. package/dist/workflow/types.d.ts +131 -0
  43. package/dist/workflow/types.d.ts.map +1 -0
  44. package/dist/workflow/types.js +17 -0
  45. package/dist/workflow/types.js.map +1 -0
  46. package/dist/workspace/filename.d.ts +33 -0
  47. package/dist/workspace/filename.d.ts.map +1 -0
  48. package/dist/workspace/filename.js +98 -0
  49. package/dist/workspace/filename.js.map +1 -0
  50. package/dist/workspace/frontmatter.d.ts +23 -0
  51. package/dist/workspace/frontmatter.d.ts.map +1 -0
  52. package/dist/workspace/frontmatter.js +48 -0
  53. package/dist/workspace/frontmatter.js.map +1 -0
  54. package/dist/workspace/kb-layout.d.ts +66 -0
  55. package/dist/workspace/kb-layout.d.ts.map +1 -0
  56. package/dist/workspace/kb-layout.js +63 -0
  57. package/dist/workspace/kb-layout.js.map +1 -0
  58. package/dist/workspace/types.d.ts +57 -0
  59. package/dist/workspace/types.d.ts.map +1 -0
  60. package/dist/workspace/types.js +2 -0
  61. package/dist/workspace/types.js.map +1 -0
  62. package/package.json +41 -0
  63. package/src/auth/types.ts +16 -0
  64. package/src/chat/types.ts +53 -0
  65. package/src/git/branchAuthor.ts +46 -0
  66. package/src/git/pr.types.ts +274 -0
  67. package/src/git/protected.ts +121 -0
  68. package/src/git/review.types.ts +51 -0
  69. package/src/git/types.ts +170 -0
  70. package/src/index.ts +23 -0
  71. package/src/workflow/events.ts +271 -0
  72. package/src/workflow/interface.ts +357 -0
  73. package/src/workflow/types.ts +156 -0
  74. package/src/workspace/filename.ts +102 -0
  75. package/src/workspace/frontmatter.ts +45 -0
  76. package/src/workspace/kb-layout.ts +81 -0
  77. package/src/workspace/types.ts +59 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Workflow event bus types — what the backend pushes over SSE to keep the
3
+ * frontend in sync with state changes, including those triggered by other
4
+ * users on the same branch or by the agent acting server-side.
5
+ *
6
+ * **Scope model** — events have one of three scopes, set by the variant:
7
+ *
8
+ * - **Workspace-scoped** (`workspaceId` field). Pushed to every connected
9
+ * session whose current focus is `workspaceId`. Examples: a file changed
10
+ * on this branch, someone took the lock on a file. Anyone editing this
11
+ * branch cares.
12
+ *
13
+ * - **User-scoped** (`forUserId` field, no `workspaceId`). Pushed only to
14
+ * sessions opened by `forUserId`, regardless of their current focus.
15
+ * Examples: an agent turn called a tool on your behalf, your view should
16
+ * navigate to a different branch. Other users on the same branch don't
17
+ * care.
18
+ *
19
+ * - **Global** (no `workspaceId`, no `forUserId`). Pushed to every
20
+ * authenticated session. Examples: change requests opening / merging /
21
+ * being rejected — any user with access to the repo might care.
22
+ *
23
+ * **Identity + replay** — every event carries an `id` (monotonic int) and an
24
+ * ISO `ts`. Clients reconnecting with `Last-Event-ID: <id>` get the buffered
25
+ * tail replayed (server's ring buffer is finite — see `WorkflowEventBus`).
26
+ * If `id` is older than what the buffer still has, the server sends a
27
+ * `{ kind: 'resync' }` instead so the client refetches the world cleanly.
28
+ *
29
+ * **Idempotency over exactly-once** — clients tolerate duplicates and
30
+ * out-of-order delivery: every payload carries enough state for the UI to
31
+ * reach the right end state regardless of how many times it sees the event
32
+ * (e.g. `file-changed` includes the new SHA — UI only refreshes if the open
33
+ * tab's SHA differs).
34
+ */
35
+
36
+ export interface WorkflowEventEnvelope {
37
+ /** Monotonic, process-local. Used for `Last-Event-ID` replay. */
38
+ id: number;
39
+ /** ISO timestamp the event was emitted on the backend. */
40
+ ts: string;
41
+ }
42
+
43
+ // ── Workspace-scoped ─────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * A file's content changed on disk — fires for ANY write that lands bytes
47
+ * for `(workspaceId, branch, path)`, whether or not a commit followed.
48
+ *
49
+ * - **`newSha` set** → a commit landed. The disk write came through the
50
+ * full lock-release pipeline (acquire → write → commitFile → push).
51
+ * History views should refetch.
52
+ *
53
+ * - **`newSha === null`** → disk bytes changed but no commit yet, i.e.
54
+ * the writer is mid-edit (lock still held — e.g. the editor's Ctrl+S
55
+ * autosave-to-disk before the final Save click). Watchers should
56
+ * refetch content but NOT history (nothing new in git log).
57
+ *
58
+ * Emitted from both the human PUT/PATCH/DELETE routes and the agent's
59
+ * LockingFilesystem so anyone tailing the file sees changes immediately.
60
+ */
61
+ export interface FileChangedEvent {
62
+ kind: 'file-changed';
63
+ workspaceId: string;
64
+ branch: string;
65
+ path: string;
66
+ /** Commit sha if a commit landed; null if this was a disk-only write. */
67
+ newSha: string | null;
68
+ /** The user whose write caused the change. */
69
+ byUserId: string;
70
+ byUserName: string;
71
+ }
72
+
73
+ /** Someone (`holderUserId`) just acquired the lock on `(branch, path)`. */
74
+ export interface LockAcquiredEvent {
75
+ kind: 'lock-acquired';
76
+ workspaceId: string;
77
+ branch: string;
78
+ path: string;
79
+ holderUserId: string;
80
+ holderName: string;
81
+ }
82
+
83
+ /** The lock on `(branch, path)` was released — file is free for the next caller. */
84
+ export interface LockReleasedEvent {
85
+ kind: 'lock-released';
86
+ workspaceId: string;
87
+ branch: string;
88
+ path: string;
89
+ }
90
+
91
+ /**
92
+ * The file tree for `(workspaceId, branch)` changed in a way that's not
93
+ * captured by a single `file-changed` event — typically a recursive delete,
94
+ * a move, or an unzip extracting many files. Emitted alongside per-file
95
+ * `file-changed` events, but lets the file explorer refresh once instead of
96
+ * N times.
97
+ */
98
+ export interface FsTreeChangedEvent {
99
+ kind: 'fs-tree-changed';
100
+ workspaceId: string;
101
+ branch: string;
102
+ }
103
+
104
+ // ── User-scoped ──────────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Agent called `switch_branch` mid-turn and the target validated +
108
+ * pre-warmed successfully. Tells the frontend to navigate to the new draft
109
+ * once the in-flight chat turn completes — navigation mid-stream would tear
110
+ * down the chat connection. The chat hook stashes the latest pending
111
+ * destination and consumes it in `onFinish`. The native chat path stamps the
112
+ * `threadId`; the registry `switch_branch` tool (called over the internal
113
+ * loopback, which carries no threadId) omits it, so the frontend keys the
114
+ * pending destination on `forUserId` — a user has one active chat stream.
115
+ *
116
+ * User-scoped (no `workspaceId`) — only the user whose agent ran the tool
117
+ * should follow; other users on the same branch don't care.
118
+ */
119
+ export interface BranchSwitchedEvent {
120
+ kind: 'branch-switched';
121
+ forUserId: string;
122
+ threadId?: string;
123
+ branch: string;
124
+ workspaceId: string;
125
+ }
126
+
127
+ /**
128
+ * Agent (running on `forUserId`'s behalf) is about to call a tool — used by
129
+ * the file explorer to highlight files the agent is touching and by the
130
+ * chat sidebar to show a live activity feed without parsing the chat
131
+ * stream's data parts.
132
+ *
133
+ * Fired BEFORE the tool runs; the resulting `file-changed` /
134
+ * `change-request-opened` / etc. events arrive after.
135
+ */
136
+ export interface AgentToolCallEvent {
137
+ kind: 'agent-tool-call';
138
+ forUserId: string;
139
+ threadId: string;
140
+ tool: string;
141
+ /**
142
+ * Free-form summary of what the tool is about to do (e.g. `"writing
143
+ * Knowledge/Foo.md"`, `"opening change request Foo → target-company-state"`).
144
+ * Optional — purely informational, never load-bearing.
145
+ */
146
+ summary?: string;
147
+ }
148
+
149
+ // ── Global ───────────────────────────────────────────────────────────────────
150
+
151
+ export interface ChangeRequestOpenedEvent {
152
+ kind: 'change-request-opened';
153
+ number: number;
154
+ source: string;
155
+ target: string;
156
+ /** Hash of the author's email (matches the marker in the CR body); null when unattributable. */
157
+ authorIdHash: string | null;
158
+ title: string;
159
+ }
160
+
161
+ export interface ChangeRequestMergedEvent {
162
+ kind: 'change-request-merged';
163
+ number: number;
164
+ }
165
+
166
+ export interface ChangeRequestRejectedEvent {
167
+ kind: 'change-request-rejected';
168
+ number: number;
169
+ }
170
+
171
+ /**
172
+ * A merge the caller triggered did NOT land — either the gate refused it, the
173
+ * branch needs conflict resolution, or `gh pr merge` failed. The merge route
174
+ * is async (returns 202 immediately so a large PR's merge can't outlive the
175
+ * gateway timeout), so this is how the failure reaches the UI that kicked it
176
+ * off. Success travels on `change-request-merged` instead.
177
+ *
178
+ * User-scoped (`forUserId`, no `workspaceId`) — a failed merge changes no
179
+ * shared state; only the user who clicked needs the reason, so we don't
180
+ * broadcast the error string to every session.
181
+ */
182
+ export interface ChangeRequestMergeFailedEvent {
183
+ kind: 'change-request-merge-failed';
184
+ forUserId: string;
185
+ number: number;
186
+ /** Human-readable reason for the error banner (tokens already redacted). */
187
+ reason: string;
188
+ /**
189
+ * True when the merge failed because the branch needs conflict resolution.
190
+ * The UI routes to the agent resolution flow instead of showing an error.
191
+ */
192
+ conflicts: boolean;
193
+ }
194
+
195
+ /**
196
+ * Per-file approval on a change request was added or removed (e.g. via the
197
+ * Approve / Withdraw confirmation buttons, or auto-invalidated by a new
198
+ * commit). The UI re-fetches the CR detail to get the updated approval set.
199
+ */
200
+ export interface ApprovalChangedEvent {
201
+ kind: 'approval-changed';
202
+ number: number;
203
+ path: string;
204
+ approverUserId: string;
205
+ change: 'added' | 'removed';
206
+ }
207
+
208
+ // ── Control ──────────────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Sent every ~25 s on the SSE stream to keep proxies / load balancers from
212
+ * killing idle connections. Carries no payload state — the client ignores it
213
+ * apart from the keepalive effect.
214
+ */
215
+ export interface HeartbeatEvent {
216
+ kind: 'heartbeat';
217
+ }
218
+
219
+ /**
220
+ * Server told the client to discard its cached state and refetch from
221
+ * scratch — sent when a reconnecting client's `Last-Event-ID` is older than
222
+ * the server's ring buffer still has, so we can't trust an event replay to
223
+ * catch them up.
224
+ */
225
+ export interface ResyncEvent {
226
+ kind: 'resync';
227
+ /** Human-readable reason for logs / debugging. */
228
+ reason: string;
229
+ }
230
+
231
+ // ── Union + helpers ──────────────────────────────────────────────────────────
232
+
233
+ export type WorkflowEventPayload =
234
+ | FileChangedEvent
235
+ | LockAcquiredEvent
236
+ | LockReleasedEvent
237
+ | FsTreeChangedEvent
238
+ | BranchSwitchedEvent
239
+ | AgentToolCallEvent
240
+ | ChangeRequestOpenedEvent
241
+ | ChangeRequestMergedEvent
242
+ | ChangeRequestRejectedEvent
243
+ | ChangeRequestMergeFailedEvent
244
+ | ApprovalChangedEvent
245
+ | HeartbeatEvent
246
+ | ResyncEvent;
247
+
248
+ export type WorkflowEvent = WorkflowEventEnvelope & WorkflowEventPayload;
249
+
250
+ /** True for events scoped to a workspace — the focus filter applies. */
251
+ export function isWorkspaceScoped(
252
+ e: WorkflowEventPayload,
253
+ ): e is WorkflowEventPayload & { workspaceId: string } {
254
+ return (
255
+ e.kind === 'file-changed' ||
256
+ e.kind === 'lock-acquired' ||
257
+ e.kind === 'lock-released' ||
258
+ e.kind === 'fs-tree-changed'
259
+ );
260
+ }
261
+
262
+ /** True for events scoped to a specific user — the user filter applies. */
263
+ export function isUserScoped(
264
+ e: WorkflowEventPayload,
265
+ ): e is WorkflowEventPayload & { forUserId: string } {
266
+ return (
267
+ e.kind === 'agent-tool-call' ||
268
+ e.kind === 'branch-switched' ||
269
+ e.kind === 'change-request-merge-failed'
270
+ );
271
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * `IWorkflowService` — the abstraction the rest of the app talks to instead
3
+ * of raw git/gh. See `bevel-platform/PLAN.md` for the motivation and the spec the
4
+ * methods here encode (protected branches, atomic per-file changes, file
5
+ * locks, change requests as branch-pair reconciliations).
6
+ *
7
+ * Naming convention: methods speak workflow vocabulary (`commitChange`,
8
+ * `openChangeRequest`, `acquireLock`), never git vocabulary (`commit`,
9
+ * `createPr`, no analogue for locks). Implementations may delegate to the
10
+ * existing git/PR/review-workflow services until those are folded into
11
+ * `modules/workflow`; callers should not depend on that fact.
12
+ *
13
+ * Some methods on this interface have no backing service yet — file locks,
14
+ * `openChangeRequest`, `updateFromTarget`. The facade implementation throws
15
+ * `NotImplementedWorkflowError` from those until the underlying support
16
+ * lands. The interface defines them up-front so consumers can plan against
17
+ * the final surface area.
18
+ */
19
+
20
+ import type { AuthUser } from '../auth/types.js';
21
+ import type {
22
+ AcquireLockResult,
23
+ Branch,
24
+ BranchWorkspaceStatus,
25
+ CancelChangeRequestResult,
26
+ Change,
27
+ ChangeInput,
28
+ ChangeRequest,
29
+ ChangeRequestComment,
30
+ ChangeRequestDetail,
31
+ ChangeRequestState,
32
+ ChangedFile,
33
+ FileApproval,
34
+ FileLock,
35
+ MergeChangeRequestOutcome,
36
+ OpenChangeRequestInput,
37
+ PostChangeRequestCommentInput,
38
+ } from './types.js';
39
+
40
+ export interface IWorkflowService {
41
+ // ── Branches ──────────────────────────────────────────────────────────────
42
+
43
+ listBranches(workspaceId: string, opts?: { freshFetch?: boolean }): Promise<Branch[]>;
44
+ /**
45
+ * Create a new unprotected branch. Protected branches cannot be created
46
+ * via the workflow — the protected set is bootstrapped from the KB repo's
47
+ * initial state and never grown at runtime.
48
+ */
49
+ createBranch(workspaceId: string, name: string, fromBase?: string): Promise<Branch>;
50
+ /**
51
+ * Delete a branch. Removes both local and origin refs in one call. The
52
+ * branch's author (per `<email-localpart>/...` naming) OR an admin (per the
53
+ * workspace's `roles.yaml`) can delete it. Admins can additionally remove
54
+ * unprefixed CLI-created branches that have no recognisable author.
55
+ *
56
+ * `onlyIfNoRemote: true` is the legacy orphan-cleanup path: bypasses the
57
+ * author/admin check (callers prune any orphan they encounter) and refuses
58
+ * if origin still has the ref. Protected and current branches always reject.
59
+ */
60
+ deleteBranch(
61
+ workspaceId: string,
62
+ name: string,
63
+ user: AuthUser,
64
+ opts?: { onlyIfNoRemote?: boolean },
65
+ ): Promise<void>;
66
+ // `switchBranch` removed: under the per-branch workspace model the active
67
+ // branch is the workspace's identity. Switching branches is a workspace
68
+ // selection (`WorkspaceService.getOrCreateForBranch`), not an operation
69
+ // on an existing workspace's clone.
70
+ // `forkCurrentToDraft` removed: under the per-branch workspace model each
71
+ // branch is its own workspace; nothing carries uncommitted edits across.
72
+ // `createBranch` + URL navigation covers the "start a new draft" intent.
73
+ /** Current branch's workspace status — currently just the branch name + upstream sync state. */
74
+ branchStatus(workspaceId: string): Promise<BranchWorkspaceStatus>;
75
+ // `discardWorkingChanges` removed: under save=share the working tree is
76
+ // never dirty, so there is nothing for "undo my unsaved edits" to undo.
77
+ /**
78
+ * Push the current branch upstream so other users (and change requests)
79
+ * can see the commits on it. Workflow term: "share current branch". The
80
+ * underlying implementation is `git push -u origin <branch>`.
81
+ */
82
+ shareCurrentBranch(workspaceId: string, user: AuthUser): Promise<void>;
83
+ /**
84
+ * Number of commits on `branch` that aren't on `baseBranch` — exactly
85
+ * what a CR from `branch` to `baseBranch` would propose for review.
86
+ * Implemented as `git rev-list --count <baseBranch>..<branch>` against
87
+ * the workspace's clone; returns 0 when the branch has no unmerged
88
+ * commits (a fresh fork that nobody wrote to) and when either ref
89
+ * cannot be resolved. Background runs use this to decide whether a
90
+ * routine branch deserves a change request — without it, a run where
91
+ * the agent commits files but forgets to call `open_change_request`
92
+ * would silently lose its work to the no-changes cleanup path.
93
+ */
94
+ countCommitsAhead(workspaceId: string, branch: string, baseBranch: string): Promise<number>;
95
+ /** Fetch remote refs without modifying the working tree. */
96
+ refreshRemotes(workspaceId: string): Promise<void>;
97
+ /** Pull the latest remote state into the current branch (rebase strategy). */
98
+ updateFromRemote(workspaceId: string): Promise<void>;
99
+ /**
100
+ * Hard-reset the workspace's checked-out branch to `origin/<branch>` (fetch
101
+ * first), discarding any local divergence. Break-glass primitive for
102
+ * roles.yaml recovery so the restoring commit can fast-forward on push.
103
+ */
104
+ resetToRemote(workspaceId: string, branch: string): Promise<void>;
105
+ /**
106
+ * Best-fit protected branch that `branch` was forked from — picks the
107
+ * protected branch on origin yielding the smallest "ahead" count.
108
+ * Returns null if no protected branch can be reached.
109
+ */
110
+ resolveForkBase(workspaceId: string, branch: string): Promise<string | null>;
111
+ /**
112
+ * Paths that the next `commitChange` would include — what `git add -A`
113
+ * would stage: modified + deleted + renamed + untracked (honouring
114
+ * `.gitignore`). Returns only paths.
115
+ */
116
+ listPendingChangePaths(workspaceId: string): Promise<string[]>;
117
+ // `listWorkingChanges` + `diffFileInWorking` removed: under save=share the
118
+ // working tree is never dirty, so both report the empty state by
119
+ // definition. Cross-branch / per-commit diff cases are covered by
120
+ // `compareFile` + `showFileAtChange` below.
121
+
122
+ // ── Changes ───────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Commit one atomic change. The current backing implementation packs every
126
+ * dirty path in the working tree into the commit; the workflow spec
127
+ * eventually constrains this to one file per change, enforced at the
128
+ * commit boundary. Until that lands, callers should already commit
129
+ * one-file-at-a-time by convention.
130
+ */
131
+ commitChange(workspaceId: string, user: AuthUser, input: ChangeInput): Promise<Change>;
132
+ /**
133
+ * Commit whatever the caller has already written to the working tree as ONE
134
+ * atomic change, then push. Unlike `commitChange` (one file at a time, guarded
135
+ * at the commit boundary) this commits the whole dirty set together — for the
136
+ * rare case where a group of files must land together or not at all (e.g. a
137
+ * role rename rewriting `roles.yaml` + every reference). Does NOT write
138
+ * content: the caller writes the files first (e.g. via the lock-aware
139
+ * filesystem) and is responsible for any validation. Returns null on a no-op
140
+ * (clean tree).
141
+ */
142
+ commitChanges(
143
+ workspaceId: string,
144
+ user: AuthUser,
145
+ summary: string,
146
+ ): Promise<Change | null>;
147
+ /** Per-file history. Newest first; clamps to ≤ 100 entries. */
148
+ listChangesForFile(workspaceId: string, path: string, limit?: number): Promise<Change[]>;
149
+ /**
150
+ * Revert a single change by creating a new change that undoes it.
151
+ * Refused on protected branches; refused without write permission on the
152
+ * touched paths.
153
+ */
154
+ revertChange(workspaceId: string, user: AuthUser, sha: string): Promise<Change>;
155
+ /**
156
+ * Diff of one file between two branches. Both names must resolve to a
157
+ * known branch on this workspace (local head or remote-tracking). Returns
158
+ * the raw unified diff body — empty when identical, includes the
159
+ * "new file" / "deleted file" headers when the file only exists on one
160
+ * side.
161
+ */
162
+ compareFile(
163
+ workspaceId: string,
164
+ path: string,
165
+ fromBranch: string,
166
+ toBranch: string,
167
+ ): Promise<string>;
168
+ /**
169
+ * Diff of one file at a specific change (sha) — i.e. `git show <sha> -- <path>`.
170
+ * Used by the File viewer's History tab.
171
+ */
172
+ showFileAtChange(workspaceId: string, path: string, sha: string): Promise<string>;
173
+
174
+ // ── File locks (new — currently NotImplementedWorkflowError) ──────────────
175
+
176
+ /**
177
+ * Try to acquire an edit lock on `(branch, path)` for the caller. Returns
178
+ * `{ acquired: true, lock }` on success; `{ acquired: false, lock }` if
179
+ * someone else already holds it (the existing lock is included so the UI
180
+ * can render "Locked by X" without a follow-up call).
181
+ *
182
+ * Pessimistic per the spec. Auto-released when the lock expires past TTL
183
+ * without a heartbeat — stale locks must not block forever.
184
+ */
185
+ acquireLock(
186
+ workspaceId: string,
187
+ branch: string,
188
+ path: string,
189
+ user: AuthUser,
190
+ ): Promise<AcquireLockResult>;
191
+ /** Heartbeat to keep an acquired lock alive past its current TTL. */
192
+ heartbeatLock(workspaceId: string, branch: string, path: string, user: AuthUser): Promise<FileLock>;
193
+ /**
194
+ * Commit the lock-held file's accumulated edits as a change *without*
195
+ * releasing the lock. Used by the client-side autosave checkpoint timer
196
+ * so the lock stays exclusively the caller's across many interim
197
+ * commits — releasing + reacquiring around every checkpoint would let
198
+ * another user grab the lock in the gap. Returns `null` when the file
199
+ * has no pending edits (idempotent). Refused when the lock isn't held
200
+ * by `user`.
201
+ */
202
+ commitFileWhileLocked(
203
+ workspaceId: string,
204
+ branch: string,
205
+ path: string,
206
+ user: AuthUser,
207
+ summary?: string,
208
+ ): Promise<Change | null>;
209
+
210
+ /**
211
+ * Release a held lock. The "I'm done editing this file" terminal signal.
212
+ *
213
+ * **Does not commit or push synchronously.** This method enqueues a row in
214
+ * `pending_commits` and drops the lock, then returns `void`. The background
215
+ * `PendingCommitsWorker` claims the row, synthesises the commit subject
216
+ * (callers do *not* supply one — the worker derives a default from the path
217
+ * and any worker-side metadata), runs `commitFile` against whatever bytes
218
+ * are on disk, and pushes the result. Decoupling the user-visible lock
219
+ * release from git means a transient push failure no longer wedges the
220
+ * editor, and a process crash mid-commit becomes resumable instead of
221
+ * leaving an orphan on disk. See `lock-decoupling-plan.md` for the full
222
+ * design.
223
+ *
224
+ * The autosave-checkpoint timer and explicit `commitChange` calls land
225
+ * commits directly (no queue); `releaseLock` is queue-only.
226
+ */
227
+ releaseLock(
228
+ workspaceId: string,
229
+ branch: string,
230
+ path: string,
231
+ user: AuthUser,
232
+ ): Promise<void>;
233
+ /**
234
+ * Drop the lock **without committing**. Used on the failure path of a
235
+ * lock-aware write — when the underlying op threw, we don't want the
236
+ * normal `releaseLock` to commit + push whatever partial state happens
237
+ * to be on disk. The lock row goes away (so the next caller can edit
238
+ * the file); the working tree is left as-is. Idempotent like
239
+ * `releaseLock`: a no-op when the caller doesn't hold the lock.
240
+ */
241
+ releaseLockNoCommit(
242
+ workspaceId: string,
243
+ branch: string,
244
+ path: string,
245
+ user: AuthUser,
246
+ ): Promise<void>;
247
+ /** Current lock holder for `(branch, path)`, or null when nobody holds it. */
248
+ getLock(workspaceId: string, branch: string, path: string): Promise<FileLock | null>;
249
+
250
+ // ── Change Requests ───────────────────────────────────────────────────────
251
+
252
+ listChangeRequests(opts?: { fresh?: boolean }): Promise<ChangeRequest[]>;
253
+ /** Change requests authored by the caller (hash-matched against the body marker). */
254
+ listChangeRequestsAuthoredBy(emailOrLogin: string): Promise<ChangeRequest[]>;
255
+ /** Change requests touching files the user has write permission on. */
256
+ listChangeRequestsForUser(
257
+ workspaceId: string,
258
+ email: string,
259
+ opts?: { fresh?: boolean },
260
+ ): Promise<ChangeRequest[]>;
261
+ getChangeRequest(number: number): Promise<ChangeRequest | null>;
262
+ getChangeRequestDetail(
263
+ number: number,
264
+ opts?: { fresh?: boolean; workspaceId?: string; viewerEmail?: string },
265
+ ): Promise<ChangeRequestDetail | null>;
266
+
267
+ /**
268
+ * Open a new change request. Per spec, auto-merges `targetBranch` into
269
+ * `sourceBranch` and surfaces the resulting diff as the changes to review.
270
+ * Throws `NotImplementedWorkflowError` until the backing create-PR path
271
+ * lands in the workflow module (today the agent shells `gh pr create`
272
+ * directly).
273
+ */
274
+ openChangeRequest(
275
+ workspaceId: string,
276
+ user: AuthUser,
277
+ input: OpenChangeRequestInput,
278
+ ): Promise<ChangeRequestDetail>;
279
+
280
+ /**
281
+ * Re-run `targetBranch → sourceBranch` merge on an existing change request.
282
+ * Used when the target has advanced since the change request was opened.
283
+ * Throws `NotImplementedWorkflowError` until the backing merge path lands.
284
+ */
285
+ updateFromTarget(workspaceId: string, user: AuthUser, number: number): Promise<ChangeRequestDetail>;
286
+
287
+ // Comments
288
+ listComments(number: number): Promise<ChangeRequestComment[]>;
289
+ postComment(
290
+ number: number,
291
+ user: AuthUser,
292
+ input: PostChangeRequestCommentInput,
293
+ headSha: string,
294
+ ): Promise<ChangeRequestComment>;
295
+ editComment(commentId: string, number: number, user: AuthUser, body: string): Promise<ChangeRequestComment>;
296
+ deleteComment(commentId: string, number: number, user: AuthUser): Promise<void>;
297
+
298
+ // Approvals — per-file, tied to the file's current change. New changes
299
+ // invalidate approvals on the affected file.
300
+ approveFile(
301
+ number: number,
302
+ path: string,
303
+ user: AuthUser,
304
+ files: ChangedFile[],
305
+ headSha: string,
306
+ baseBranch: string,
307
+ authorIdHash: string | null,
308
+ workspaceId: string,
309
+ ): Promise<FileApproval[]>;
310
+ unapproveFile(
311
+ number: number,
312
+ path: string,
313
+ user: AuthUser,
314
+ files: ChangedFile[],
315
+ headSha: string,
316
+ baseBranch: string,
317
+ authorIdHash: string | null,
318
+ workspaceId: string,
319
+ ): Promise<FileApproval[]>;
320
+
321
+ /**
322
+ * Reject a change request without merging. Authorized for the author or
323
+ * for users with write permission to every changed file on the target
324
+ * branch.
325
+ */
326
+ rejectChangeRequest(
327
+ number: number,
328
+ user: AuthUser,
329
+ state: ChangeRequestState,
330
+ authorIdHash: string | null,
331
+ baseBranch: string,
332
+ workspaceId: string,
333
+ ): Promise<CancelChangeRequestResult>;
334
+
335
+ /**
336
+ * Merge a change request. The workflow tries a direct merge first; on
337
+ * conflicts it returns a `conflicts-need-resolution` outcome and the
338
+ * caller (typically the agent) is responsible for resolving them as new
339
+ * changes on the source branch. Once the source contains target cleanly,
340
+ * a subsequent `mergeChangeRequest` call lands the merge.
341
+ *
342
+ * The hard merge invariant — only this method may invoke `gh pr merge` —
343
+ * is preserved through the facade by delegating to
344
+ * `IReviewWorkflowService.mergePr`.
345
+ */
346
+ mergeChangeRequest(
347
+ number: number,
348
+ user: AuthUser,
349
+ headSha: string,
350
+ approvals: FileApproval[],
351
+ state: ChangeRequestState,
352
+ title: string,
353
+ baseBranch: string,
354
+ workspaceId: string,
355
+ opts?: { bypass?: boolean },
356
+ ): Promise<MergeChangeRequestOutcome>;
357
+ }