@cat-factory/app 0.153.3 → 0.154.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.
@@ -1,21 +1,17 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
- import type {
4
- Decision,
5
- ExecutionInstance,
6
- Pipeline,
7
- PipelineStep,
8
- StepApproval,
9
- } from '~/types/domain'
10
- import type { RequestStepChangesInput } from '@cat-factory/contracts'
11
- import type { IterationCapChoice } from '~/types/execution'
12
- import { useWorkspaceStore } from '~/stores/workspace'
3
+ import type { Decision, ExecutionInstance, PipelineStep, StepApproval } from '~/types/domain'
4
+ import { createExecutionCommands } from '~/stores/execution/commands'
13
5
 
14
6
  /**
15
7
  * Running pipeline instances. The simulation engine lives on the backend: this
16
8
  * store mirrors the server's executions and drives them via the API. Commands
17
9
  * call the worker and then refresh the workspace snapshot, since advancing an
18
10
  * execution also rolls status/progress up onto its block server-side.
11
+ *
12
+ * The run-control commands live in a cohesive factory ({@link createExecutionCommands}, under
13
+ * `stores/execution/`) that closes over the state assembled here — a size-only split mirroring
14
+ * `stores/board/`, not a new seam.
19
15
  */
20
16
  export const useExecutionStore = defineStore('execution', () => {
21
17
  const api = useApi()
@@ -225,101 +221,6 @@ export const useExecutionStore = defineStore('execution', () => {
225
221
  const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
226
222
  const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
227
223
 
228
- /**
229
- * Start `pipeline` against a block; the server marks the block in-progress. A block
230
- * pinned to an individual-usage model (Claude) needs the initiator's personal
231
- * password — supplied transparently from the local cache, and prompted via the
232
- * credential modal (then retried) when the server replies 428.
233
- */
234
- async function start(blockId: string, pipeline: Pipeline): Promise<boolean> {
235
- const ws = useWorkspaceStore()
236
- const personal = usePersonalSubscriptionsStore()
237
- // Returns false when the user cancels the personal-password prompt OR the start was
238
- // refused (a 409 conflict, surfaced as an actionable toast here), so an optimistic
239
- // caller can revert its "Starting…" state without its own error handling.
240
- try {
241
- return await personal.withCredential(async (password) => {
242
- await api.startExecution(ws.requireId(), blockId, { pipelineId: pipeline.id }, password)
243
- await ws.refresh()
244
- })
245
- } catch (e) {
246
- runErrors.present(e, 'errors.action.startFailed')
247
- return false
248
- }
249
- }
250
-
251
- // Interacting with a running individual-usage run (resolve/approve/request-changes) advances
252
- // + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
253
- // password first. It rides the cached password transparently, and — like start/retry — is
254
- // gated through `withCredential`: a within-buffer/lapsed cache re-prompts EARLY here (while
255
- // the user is present) rather than letting the run break mid-pipeline. For a non-individual
256
- // run the server ignores it and nothing prompts.
257
- async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
258
- const ws = useWorkspaceStore()
259
- const personal = usePersonalSubscriptionsStore()
260
- return await personal.withCredential(async (password) => {
261
- await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice }, password)
262
- await ws.refresh()
263
- })
264
- }
265
-
266
- /** Approve a step's gated proposal (optionally edited); the run advances. */
267
- async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
268
- const ws = useWorkspaceStore()
269
- const personal = usePersonalSubscriptionsStore()
270
- return await personal.withCredential(async (password) => {
271
- await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
272
- await ws.refresh()
273
- })
274
- }
275
-
276
- /** Request changes on a gated proposal; the step re-runs with the review. */
277
- async function requestStepChanges(
278
- instanceId: string,
279
- approvalId: string,
280
- review: RequestStepChangesInput,
281
- ) {
282
- const ws = useWorkspaceStore()
283
- const personal = usePersonalSubscriptionsStore()
284
- return await personal.withCredential(async (password) => {
285
- await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
286
- await ws.refresh()
287
- })
288
- }
289
-
290
- /** Reject a gated proposal; the run stops entirely (a retryable failure). */
291
- async function rejectStep(instanceId: string, approvalId: string, reason?: string) {
292
- const ws = useWorkspaceStore()
293
- await api.rejectStep(ws.requireId(), instanceId, approvalId, { reason })
294
- await ws.refresh()
295
- }
296
-
297
- /**
298
- * Resolve a companion step parked at its rework cap: extra-round (one more pass) /
299
- * proceed (advance with the current output) / stop-reset (cancel + reset the task).
300
- * Rides the cached personal password (gated through `withCredential`, so a within-buffer
301
- * cache re-prompts early) for the server to re-mint the run's activation before
302
- * re-dispatching on extra-round/proceed.
303
- */
304
- async function resolveCompanionExceeded(
305
- instanceId: string,
306
- approvalId: string,
307
- choice: IterationCapChoice,
308
- ) {
309
- const ws = useWorkspaceStore()
310
- const personal = usePersonalSubscriptionsStore()
311
- return await personal.withCredential(async (password) => {
312
- await api.resolveCompanionExceeded(
313
- ws.requireId(),
314
- instanceId,
315
- approvalId,
316
- { choice },
317
- password,
318
- )
319
- await ws.refresh()
320
- })
321
- }
322
-
323
224
  /** How many approval gates anywhere are awaiting a human. */
324
225
  const pendingApprovalCount = computed(() =>
325
226
  instances.value.reduce(
@@ -328,62 +229,11 @@ export const useExecutionStore = defineStore('execution', () => {
328
229
  ),
329
230
  )
330
231
 
331
- /** Merge an open PR (a task in `pr_ready`) the server completes the task. */
332
- async function mergePr(blockId: string) {
333
- const ws = useWorkspaceStore()
334
- try {
335
- await api.mergeBlock(ws.requireId(), blockId)
336
- await ws.refresh()
337
- } catch (e) {
338
- runErrors.present(e, 'errors.action.mergeFailed')
339
- }
340
- }
341
-
342
- /**
343
- * Restart a run from a chosen step: the server re-runs from `stepIndex` onward
344
- * (resetting that step + later steps' iteration counters) while preserving the
345
- * earlier steps' outputs as handoff context, and re-drives a fresh run. Like
346
- * start/retry it may dispatch an individual-usage (Claude) step, so it rides the
347
- * initiator's personal password — prompted (then retried) on a 428. Returns false
348
- * when the user cancels that prompt (nothing was restarted).
349
- */
350
- async function restartFromStep(instanceId: string, stepIndex: number): Promise<boolean> {
351
- const ws = useWorkspaceStore()
352
- const personal = usePersonalSubscriptionsStore()
353
- try {
354
- return await personal.withCredential(async (password) => {
355
- await api.restartFromStep(ws.requireId(), instanceId, stepIndex, password)
356
- await ws.refresh()
357
- })
358
- } catch (e) {
359
- runErrors.present(e, 'errors.action.restartFailed')
360
- return false
361
- }
362
- }
363
-
364
- /**
365
- * Cancel the execution running against a block and reset it to planned. `workspaceId`
366
- * defaults to the current workspace but can be pinned by callers that cancel a run for a
367
- * board the user may have since navigated away from (e.g. a deferred delete's commit).
368
- */
369
- async function cancel(blockId: string, workspaceId?: string) {
370
- const ws = useWorkspaceStore()
371
- await api.cancelExecution(workspaceId ?? ws.requireId(), blockId)
372
- instances.value = instances.value.filter((e) => e.blockId !== blockId)
373
- await ws.refresh()
374
- }
375
-
376
- /**
377
- * Stop a running execution WITHOUT deleting it: halts the container + durable driver
378
- * and records the run as `cancelled` (a retryable failure), leaving the block
379
- * `blocked`. Unlike {@link cancel} the run is kept — its steps/output stay readable on
380
- * the board and it can be retried from where it stopped. `runId` is the execution id.
381
- */
382
- async function stop(runId: string) {
383
- const ws = useWorkspaceStore()
384
- await api.stopAgentRun(ws.requireId(), runId)
385
- await ws.refresh()
386
- }
232
+ // The run-control commands (start / decide / approve / merge / restart / cancel / stop),
233
+ // extracted into a cohesive factory sharing the state above (a size-only split mirroring
234
+ // `stores/board/` and `stores/pipelines/` — behaviour is identical to the former in-closure
235
+ // functions).
236
+ const commands = createExecutionCommands({ api, runErrors, instances })
387
237
 
388
238
  return {
389
239
  instances,
@@ -398,15 +248,6 @@ export const useExecutionStore = defineStore('execution', () => {
398
248
  decisionsByBlock,
399
249
  approvalsByBlock,
400
250
  pendingApprovalCount,
401
- start,
402
- resolveDecision,
403
- approveStep,
404
- requestStepChanges,
405
- rejectStep,
406
- resolveCompanionExceeded,
407
- restartFromStep,
408
- mergePr,
409
- cancel,
410
- stop,
251
+ ...commands,
411
252
  }
412
253
  })
@@ -0,0 +1,60 @@
1
+ import type { ResyncRequest } from '~/types/domain'
2
+ import type { GitHubStoreContext } from './context'
3
+
4
+ /**
5
+ * The App-installation lifecycle: discovering/binding an installation, dropping it, and
6
+ * triggering a resync of the projections. Extracted from the store setup; each operation closes
7
+ * over the shared {@link GitHubStoreContext} so behaviour is identical to the original
8
+ * in-closure functions — the split is purely to keep every function within the size budget.
9
+ */
10
+ export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
11
+ const { api, workspace, available, connection, installations, loadingInstallations } = ctx
12
+ const { repos, availableRepos, pulls, issues, branches, syncing, load } = ctx
13
+
14
+ /** The URL a workspace owner visits to install the App against this workspace. */
15
+ function getInstallUrl(): Promise<string> {
16
+ return api.getGitHubInstallUrl(workspace.requireId()).then((r) => r.url)
17
+ }
18
+
19
+ /** Discover the App's installations so the user can connect one without typing an id. */
20
+ async function loadInstallations() {
21
+ loadingInstallations.value = true
22
+ try {
23
+ const { installations: list } = await api.listGitHubInstallations(workspace.requireId())
24
+ installations.value = list
25
+ } finally {
26
+ loadingInstallations.value = false
27
+ }
28
+ }
29
+
30
+ /** Programmatic bind by installation id (the browser flow uses the redirect). */
31
+ async function connect(installationId: number) {
32
+ connection.value = await api.connectGitHub(workspace.requireId(), installationId)
33
+ available.value = true
34
+ await load()
35
+ }
36
+
37
+ async function disconnect() {
38
+ await api.disconnectGitHub(workspace.requireId())
39
+ connection.value = null
40
+ repos.value = []
41
+ availableRepos.value = []
42
+ pulls.value = []
43
+ issues.value = []
44
+ branches.value = {}
45
+ }
46
+
47
+ /** Trigger a resync, then refresh projections (no-op for queued/backfill). */
48
+ async function resync(body: ResyncRequest = {}) {
49
+ syncing.value = true
50
+ try {
51
+ const res = await api.resyncGitHub(workspace.requireId(), body)
52
+ await load()
53
+ return res
54
+ } finally {
55
+ syncing.value = false
56
+ }
57
+ }
58
+
59
+ return { getInstallUrl, loadInstallations, connect, disconnect, resync }
60
+ }
@@ -0,0 +1,46 @@
1
+ import type { Ref } from 'vue'
2
+ import type {
3
+ GitHubBranch,
4
+ GitHubConnection,
5
+ GitHubAvailableRepo,
6
+ GitHubInstallationOption,
7
+ GitHubIssue,
8
+ GitHubPullRequest,
9
+ GitHubRepo,
10
+ RepoTreeEntry,
11
+ } from '~/types/domain'
12
+ import { useWorkspaceStore } from '~/stores/workspace'
13
+
14
+ /** Stable identity for a pull request in the `pulls` list: repo + PR number. */
15
+ export const pullKey = (repoGithubId: number, number: number) => `${repoGithubId}:${number}`
16
+
17
+ /**
18
+ * Shared reactive state + injected dependencies the GitHub-store action factories close over.
19
+ * Created once in the `github` store setup and threaded into {@link createGitHubConnectionActions}
20
+ * and {@link createGitHubRepoActions} so the split operations stay behaviourally identical to the
21
+ * original single-closure store — a size-only extraction mirroring `stores/board/` and
22
+ * `stores/pipelines/`, not a new seam.
23
+ */
24
+ export interface GitHubStoreContext {
25
+ api: ReturnType<typeof useApi>
26
+ workspace: ReturnType<typeof useWorkspaceStore>
27
+ available: Ref<boolean | null>
28
+ connection: Ref<GitHubConnection | null>
29
+ installations: Ref<GitHubInstallationOption[]>
30
+ loadingInstallations: Ref<boolean>
31
+ repos: Ref<GitHubRepo[]>
32
+ availableRepos: Ref<GitHubAvailableRepo[]>
33
+ loadingAvailable: Ref<boolean>
34
+ savingRepos: Ref<boolean>
35
+ pulls: Ref<GitHubPullRequest[]>
36
+ upsertPull: (pull: GitHubPullRequest) => void
37
+ getPull: (key: string) => GitHubPullRequest | undefined
38
+ issues: Ref<GitHubIssue[]>
39
+ branches: Ref<Record<number, GitHubBranch[]>>
40
+ repoFiles: Ref<Record<number, RepoTreeEntry[]>>
41
+ syncing: Ref<boolean>
42
+ /** Whether an App installation is bound (the store's `connected` computed). */
43
+ connected: Readonly<Ref<boolean>>
44
+ /** Reload the cached repos/PRs/issues projection (the store's `load`). */
45
+ load: () => Promise<void>
46
+ }
@@ -0,0 +1,151 @@
1
+ import type {
2
+ CreateBranchInput,
3
+ GitHubAvailableRepo,
4
+ GitHubBranch,
5
+ MergePullRequestInput,
6
+ OpenPullRequestInput,
7
+ RepoTreeEntry,
8
+ } from '~/types/domain'
9
+ import { pullKey, type GitHubStoreContext } from './context'
10
+
11
+ /**
12
+ * The per-repo reads (link picker, branches, trees, file listings) and writes (create repo /
13
+ * branch, open / merge a PR, comment). Extracted from the store setup; each operation closes
14
+ * over the shared {@link GitHubStoreContext} so behaviour is identical to the original
15
+ * in-closure functions — the split is purely to keep every function within the size budget.
16
+ */
17
+ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
18
+ const { api, workspace, connected, repos, availableRepos, loadingAvailable, savingRepos } = ctx
19
+ const { branches, repoFiles, upsertPull, getPull, load } = ctx
20
+
21
+ /**
22
+ * Load the repos the installation can access, with this workspace's link state.
23
+ * With a `q` the backend filters `owner/name` server-side (the add-service picker
24
+ * searches instead of prefetching a huge installation); without one it browses all
25
+ * (the repo-link panel). A blank/short `q` clears the list rather than fetching.
26
+ */
27
+ async function loadAvailableRepos(q?: string) {
28
+ if (!connected.value) return
29
+ if (q !== undefined && q.trim() === '') {
30
+ availableRepos.value = []
31
+ return
32
+ }
33
+ loadingAvailable.value = true
34
+ try {
35
+ availableRepos.value = await api.listGitHubAvailableRepos(workspace.requireId(), q)
36
+ } finally {
37
+ loadingAvailable.value = false
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Search the installation/PAT-accessible repos server-side WITHOUT touching the shared
43
+ * `availableRepos`/`loadingAvailable` singleton — it returns the matches to the caller instead.
44
+ * This is the reusable form behind `useRepoSearch`: two independent pickers (the add-service
45
+ * modal and the doc-task reference-repo picker) can search concurrently without clobbering
46
+ * each other's results. A blank/short `q` (or no connection) returns `[]`.
47
+ */
48
+ async function searchAvailableRepos(q: string): Promise<GitHubAvailableRepo[]> {
49
+ if (!connected.value || q.trim() === '') return []
50
+ return api.listGitHubAvailableRepos(workspace.requireId(), q)
51
+ }
52
+
53
+ /** Set the exact set of repos this workspace links, then refresh projections. */
54
+ async function setLinkedRepos(repoGithubIds: number[]) {
55
+ savingRepos.value = true
56
+ try {
57
+ repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
58
+ // Reflect the new link state in the picker and refresh PRs/issues.
59
+ const linked = new Set(repoGithubIds)
60
+ availableRepos.value = availableRepos.value.map((r) => ({
61
+ ...r,
62
+ linked: linked.has(r.githubId),
63
+ }))
64
+ await load()
65
+ } finally {
66
+ savingRepos.value = false
67
+ }
68
+ }
69
+
70
+ /** Lazily load (and cache) the branches for a single repo. */
71
+ async function loadBranches(repoGithubId: number): Promise<GitHubBranch[]> {
72
+ const list = await api.listGitHubBranches(workspace.requireId(), repoGithubId)
73
+ branches.value = { ...branches.value, [repoGithubId]: list }
74
+ return list
75
+ }
76
+
77
+ /** List one level of a (monorepo) repo's tree, for the service-directory picker. */
78
+ function loadRepoTree(repoGithubId: number, path = '') {
79
+ return api.listGitHubRepoTree(workspace.requireId(), repoGithubId, path)
80
+ }
81
+
82
+ /**
83
+ * Load (and cache) EVERY file in a repo — the whole tree in one recursive read — so a
84
+ * picker can search files by path entirely client-side (no per-keystroke server call).
85
+ * Cached by repo id so re-opening the picker for the same repo is instant; force a
86
+ * refetch with `{ reload: true }`. Mirrors the branches cache.
87
+ */
88
+ async function loadRepoFiles(
89
+ repoGithubId: number,
90
+ opts: { reload?: boolean } = {},
91
+ ): Promise<RepoTreeEntry[]> {
92
+ const cached = repoFiles.value[repoGithubId]
93
+ if (cached && !opts.reload) return cached
94
+ const files = await api.listGitHubRepoFiles(workspace.requireId(), repoGithubId)
95
+ repoFiles.value = { ...repoFiles.value, [repoGithubId]: files }
96
+ return files
97
+ }
98
+
99
+ // ---- repo writes ----------------------------------------------------------
100
+
101
+ /**
102
+ * Create a repository under the connected account (privileged App tier). Only
103
+ * meaningful when `canCreateRepos`; the backend 409s otherwise. Returns the
104
+ * created repo so the caller can confirm/link it.
105
+ */
106
+ function createRepo(input: Parameters<typeof api.createGitHubRepo>[1]) {
107
+ return api.createGitHubRepo(workspace.requireId(), input)
108
+ }
109
+
110
+ async function createBranch(repoGithubId: number, input: CreateBranchInput) {
111
+ const branch = await api.createGitHubBranch(workspace.requireId(), repoGithubId, input)
112
+ const next = branches.value[repoGithubId] ?? []
113
+ branches.value = { ...branches.value, [repoGithubId]: [branch, ...next] }
114
+ return branch
115
+ }
116
+
117
+ async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
118
+ const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
119
+ upsertPull(pr)
120
+ return pr
121
+ }
122
+
123
+ async function mergePullRequest(
124
+ repoGithubId: number,
125
+ number: number,
126
+ input: MergePullRequestInput = {},
127
+ ) {
128
+ await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
129
+ // Optimistically reflect the merge until the next sync confirms it.
130
+ const existing = getPull(pullKey(repoGithubId, number))
131
+ if (existing) upsertPull({ ...existing, state: 'closed', merged: true })
132
+ }
133
+
134
+ function comment(repoGithubId: number, number: number, body: string) {
135
+ return api.commentGitHubIssue(workspace.requireId(), repoGithubId, number, body)
136
+ }
137
+
138
+ return {
139
+ loadAvailableRepos,
140
+ searchAvailableRepos,
141
+ setLinkedRepos,
142
+ loadBranches,
143
+ loadRepoTree,
144
+ loadRepoFiles,
145
+ createRepo,
146
+ createBranch,
147
+ openPullRequest,
148
+ mergePullRequest,
149
+ comment,
150
+ }
151
+ }