@cat-factory/app 0.121.0 → 0.121.2

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.
@@ -25,6 +25,19 @@ describe('useUpsertList', () => {
25
25
  expect(items.value.map((x) => x.id)).toEqual(['b', 'a'])
26
26
  })
27
27
 
28
+ it('replaces an existing item in place under prepend (no reorder)', () => {
29
+ // The github `pulls` list is prepend (newest-first); re-opening / optimistically
30
+ // merging an existing PR must replace it where it sits, not bump it to the front.
31
+ const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
32
+ upsert({ id: 'a', v: 1 })
33
+ upsert({ id: 'b', v: 2 })
34
+ upsert({ id: 'a', v: 9 }) // existing key → replace in place
35
+ expect(items.value).toEqual([
36
+ { id: 'b', v: 2 },
37
+ { id: 'a', v: 9 },
38
+ ])
39
+ })
40
+
28
41
  it('removes by key and looks up by key', () => {
29
42
  const { items, upsert, remove, get } = useUpsertList<Item>({ key: (x) => x.id })
30
43
  upsert({ id: 'a', v: 1 })
@@ -1,5 +1,6 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { useUpsertList } from '~/composables/useUpsertList'
3
4
  import type {
4
5
  Account,
5
6
  AccountInvitation,
@@ -23,7 +24,7 @@ export const useAccountsStore = defineStore(
23
24
  () => {
24
25
  const api = useApi()
25
26
 
26
- const accounts = ref<Account[]>([])
27
+ const { items: accounts, upsert: upsertAccount } = useUpsertList<Account>({ key: (a) => a.id })
27
28
  /** Active account id (persisted so a reload keeps the same context). */
28
29
  const activeAccountId = ref<string | null>(null)
29
30
  const ready = ref(false)
@@ -46,7 +47,7 @@ export const useAccountsStore = defineStore(
46
47
  /** Create a shared org account and make it active. */
47
48
  async function createOrg(name: string) {
48
49
  const account = await api.createAccount({ name })
49
- accounts.value.push(account)
50
+ upsertAccount(account)
50
51
  activeAccountId.value = account.id
51
52
  return account
52
53
  }
@@ -62,8 +63,7 @@ export const useAccountsStore = defineStore(
62
63
  */
63
64
  async function setDefaultCloudProvider(id: string, provider: CloudProvider) {
64
65
  const updated = await api.updateAccount(id, { defaultCloudProvider: provider })
65
- const i = accounts.value.findIndex((a) => a.id === id)
66
- if (i >= 0) accounts.value[i] = updated
66
+ upsertAccount(updated)
67
67
  return updated
68
68
  }
69
69
 
@@ -73,14 +73,15 @@ export const useAccountsStore = defineStore(
73
73
  */
74
74
  async function setSpendMonthlyLimit(id: string, limit: number | null) {
75
75
  const updated = await api.updateAccount(id, { spendMonthlyLimit: limit })
76
- const i = accounts.value.findIndex((a) => a.id === id)
77
- if (i >= 0) accounts.value[i] = updated
76
+ upsertAccount(updated)
78
77
  return updated
79
78
  }
80
79
 
81
80
  // ---- members + invitations -------------------------------------------
82
81
 
83
- const members = ref<AccountMember[]>([])
82
+ const { items: members, upsert: upsertMember } = useUpsertList<AccountMember>({
83
+ key: (m) => m.userId,
84
+ })
84
85
  const invitations = ref<AccountInvitation[]>([])
85
86
 
86
87
  /** Load the active account's member roster + pending invitations. */
@@ -108,8 +109,7 @@ export const useAccountsStore = defineStore(
108
109
  /** Set a member's role set (admin-only); patches the loaded roster in place. */
109
110
  async function setMemberRoles(accountId: string, userId: string, roles: AccountRole[]) {
110
111
  const updated = await api.setMemberRoles(accountId, userId, roles)
111
- const i = members.value.findIndex((m) => m.userId === userId)
112
- if (i >= 0) members.value[i] = updated
112
+ upsertMember(updated)
113
113
  return updated
114
114
  }
115
115
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach } from 'vitest'
2
- import type { BootstrapJob } from '~/types/domain'
2
+ import type { BootstrapJob, EnvConfigRepairJob } from '~/types/domain'
3
3
  import { useAgentRunsStore } from '~/stores/agentRuns'
4
4
 
5
5
  /** Minimal BootstrapJob factory — only the fields the store's reconcile logic touches. */
@@ -72,3 +72,56 @@ describe('agentRuns store — monotonic bootstrap reconcile', () => {
72
72
  expect(store.bootstrapJobs[0]!.status).toBe('succeeded')
73
73
  })
74
74
  })
75
+
76
+ /** Minimal EnvConfigRepairJob factory — only the fields the store touches. */
77
+ function repairJob(id: string, over: Partial<EnvConfigRepairJob> = {}): EnvConfigRepairJob {
78
+ return {
79
+ id,
80
+ workspaceId: 'ws_test',
81
+ owner: 'acme',
82
+ repo: 'svc',
83
+ branch: 'main',
84
+ status: 'running',
85
+ ok: null,
86
+ issues: [],
87
+ subtasks: null,
88
+ error: null,
89
+ failure: null,
90
+ createdAt: 1,
91
+ updatedAt: 1,
92
+ ...over,
93
+ }
94
+ }
95
+
96
+ describe('agentRuns store — env-config-repair (plain useUpsertList adoption, candidate #3)', () => {
97
+ let store: ReturnType<typeof useAgentRunsStore>
98
+ beforeEach(() => {
99
+ store = useAgentRunsStore()
100
+ })
101
+
102
+ it('upsertEnvConfigRepair prepends a new run (newest-first) and replaces in place by id', () => {
103
+ store.upsertEnvConfigRepair(repairJob('r1'))
104
+ store.upsertEnvConfigRepair(repairJob('r2'))
105
+ // Newest prepended, so r2 leads r1.
106
+ expect(store.envConfigRepairJobs.map((j) => j.id)).toEqual(['r2', 'r1'])
107
+ // A later event for r1 replaces it IN PLACE (no reorder), unlike a monotonic-guarded list.
108
+ store.upsertEnvConfigRepair(repairJob('r1', { status: 'succeeded' }))
109
+ expect(store.envConfigRepairJobs.map((j) => j.id)).toEqual(['r2', 'r1'])
110
+ expect(store.envConfigRepairById('r1')?.status).toBe('succeeded')
111
+ })
112
+
113
+ it('envConfigRepairById looks a run up by id (absent → undefined)', () => {
114
+ store.upsertEnvConfigRepair(repairJob('r1'))
115
+ expect(store.envConfigRepairById('r1')?.id).toBe('r1')
116
+ expect(store.envConfigRepairById('nope')).toBeUndefined()
117
+ })
118
+
119
+ it('hydrateEnvConfigRepair replaces the cache newest-first', () => {
120
+ store.upsertEnvConfigRepair(repairJob('stale'))
121
+ store.hydrateEnvConfigRepair([
122
+ repairJob('old', { createdAt: 1 }),
123
+ repairJob('new', { createdAt: 9 }),
124
+ ])
125
+ expect(store.envConfigRepairJobs.map((j) => j.id)).toEqual(['new', 'old'])
126
+ })
127
+ })
@@ -9,6 +9,7 @@ import type {
9
9
  } from '~/types/domain'
10
10
  import { useWorkspaceStore } from '~/stores/workspace'
11
11
  import { useExecutionStore } from '~/stores/execution'
12
+ import { useUpsertList } from '~/composables/useUpsertList'
12
13
 
13
14
  /**
14
15
  * A coarse, per-block view of the current "agent run" against a block, regardless
@@ -62,9 +63,15 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
62
63
  * Env-config-repair runs for this workspace, newest-first. These have NO board block —
63
64
  * they're surfaced only on the infrastructure-providers window (looked up by the
64
65
  * `repairJobId` the `bootstrapRepo` response returned), so they're held separately and
65
- * NOT merged into {@link byBlock}.
66
+ * NOT merged into {@link byBlock}. Unlike the bootstrap list this is a PLAIN find-by-id
67
+ * upsert (no `updatedAt` monotonic guard), so it routes through the shared
68
+ * {@link useUpsertList} helper (the last plain-upsert holdout, refactoring candidate #3).
66
69
  */
67
- const envConfigRepairJobs = ref<EnvConfigRepairJob[]>([])
70
+ const {
71
+ items: envConfigRepairJobs,
72
+ upsert: upsertEnvConfigRepair,
73
+ get: envConfigRepairById,
74
+ } = useUpsertList<EnvConfigRepairJob>({ key: (j) => j.id, prepend: true })
68
75
 
69
76
  /**
70
77
  * Reconcile the cached bootstrap runs with a server snapshot for `workspaceId`. A snapshot is
@@ -96,27 +103,11 @@ export const useAgentRunsStore = defineStore('agentRuns', () => {
96
103
  bootstrapJobs.value = [...reconciled, ...preserved].sort((a, b) => b.createdAt - a.createdAt)
97
104
  }
98
105
 
99
- /** Replace the cached env-config-repair runs with a server snapshot. */
106
+ /** Replace the cached env-config-repair runs with a server snapshot (newest-first). */
100
107
  function hydrateEnvConfigRepair(jobs: EnvConfigRepairJob[]) {
101
108
  envConfigRepairJobs.value = [...jobs].sort((a, b) => b.createdAt - a.createdAt)
102
109
  }
103
110
 
104
- /**
105
- * Patch an env-config-repair run from a real-time `env-config-repair` event (or after
106
- * launching one): replace it in place by id, else prepend it. Keeps the infra window's
107
- * "repairing…" indicator reactive to live progress / outcome without a refetch.
108
- */
109
- function upsertEnvConfigRepair(job: EnvConfigRepairJob) {
110
- const i = envConfigRepairJobs.value.findIndex((j) => j.id === job.id)
111
- if (i >= 0) envConfigRepairJobs.value[i] = job
112
- else envConfigRepairJobs.value.unshift(job)
113
- }
114
-
115
- /** Look up a single env-config-repair run by id (the infra window tracks one by `repairJobId`). */
116
- function envConfigRepairById(id: string): EnvConfigRepairJob | undefined {
117
- return envConfigRepairJobs.value.find((j) => j.id === id)
118
- }
119
-
120
111
  /**
121
112
  * Patch a bootstrap run from a real-time `bootstrap` event (or after launching
122
113
  * one): replace it in place by id, else prepend it. Keeps the service card
@@ -6,6 +6,7 @@ import type {
6
6
  ReferenceArchitecture,
7
7
  UpdateReferenceArchitectureInput,
8
8
  } from '~/types/domain'
9
+ import { useUpsertList } from '~/composables/useUpsertList'
9
10
  import { useWorkspaceStore } from '~/stores/workspace'
10
11
  import { useAgentRunsStore } from '~/stores/agentRuns'
11
12
 
@@ -27,7 +28,11 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
27
28
 
28
29
  /** null = unknown (not probed yet), true/false = module reachable or not. */
29
30
  const available = ref<boolean | null>(null)
30
- const architectures = ref<ReferenceArchitecture[]>([])
31
+ const {
32
+ items: architectures,
33
+ upsert: upsertArchitecture,
34
+ remove: dropArchitecture,
35
+ } = useUpsertList<ReferenceArchitecture>({ key: (a) => a.id, prepend: true })
31
36
  const loading = ref(false)
32
37
 
33
38
  const hasArchitectures = computed(() => architectures.value.length > 0)
@@ -50,22 +55,21 @@ export const useBootstrapStore = defineStore('bootstrap', () => {
50
55
  /** Register a new reference architecture. */
51
56
  async function createArchitecture(input: CreateReferenceArchitectureInput) {
52
57
  const created = await api.createReferenceArchitecture(workspace.requireId(), input)
53
- architectures.value.unshift(created)
58
+ upsertArchitecture(created)
54
59
  return created
55
60
  }
56
61
 
57
62
  /** Patch a reference architecture. */
58
63
  async function updateArchitecture(id: string, input: UpdateReferenceArchitectureInput) {
59
64
  const updated = await api.updateReferenceArchitecture(workspace.requireId(), id, input)
60
- const i = architectures.value.findIndex((a) => a.id === id)
61
- if (i >= 0) architectures.value[i] = updated
65
+ upsertArchitecture(updated)
62
66
  return updated
63
67
  }
64
68
 
65
69
  /** Remove a reference architecture. */
66
70
  async function deleteArchitecture(id: string) {
67
71
  await api.deleteReferenceArchitecture(workspace.requireId(), id)
68
- architectures.value = architectures.value.filter((a) => a.id !== id)
72
+ dropArchitecture(id)
69
73
  }
70
74
 
71
75
  /**
@@ -14,9 +14,13 @@ import type {
14
14
  ResyncRequest,
15
15
  } from '~/types/domain'
16
16
  import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
17
+ import { useUpsertList } from '~/composables/useUpsertList'
17
18
  import { useWorkspaceStore } from '~/stores/workspace'
18
19
  import { useServicesStore } from '~/stores/services'
19
20
 
21
+ /** Stable identity for a pull request in the `pulls` list: repo + PR number. */
22
+ const pullKey = (repoGithubId: number, number: number) => `${repoGithubId}:${number}`
23
+
20
24
  /**
21
25
  * GitHub integration state: the workspace's App installation, the projected
22
26
  * repos/branches/pull-requests/issues the backend caches in D1, and the actions
@@ -42,7 +46,14 @@ export const useGitHubStore = defineStore('github', () => {
42
46
  const availableRepos = ref<GitHubAvailableRepo[]>([])
43
47
  const loadingAvailable = ref(false)
44
48
  const savingRepos = ref(false)
45
- const pulls = ref<GitHubPullRequest[]>([])
49
+ const {
50
+ items: pulls,
51
+ upsert: upsertPull,
52
+ get: getPull,
53
+ } = useUpsertList<GitHubPullRequest>({
54
+ key: (p) => pullKey(p.repoGithubId, p.number),
55
+ prepend: true,
56
+ })
46
57
  const issues = ref<GitHubIssue[]>([])
47
58
  /** Branches loaded lazily per repo (by GitHub numeric id). */
48
59
  const branches = ref<Record<number, GitHubBranch[]>>({})
@@ -269,11 +280,7 @@ export const useGitHubStore = defineStore('github', () => {
269
280
 
270
281
  async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
271
282
  const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
272
- const i = pulls.value.findIndex(
273
- (p) => p.repoGithubId === pr.repoGithubId && p.number === pr.number,
274
- )
275
- if (i >= 0) pulls.value[i] = pr
276
- else pulls.value.unshift(pr)
283
+ upsertPull(pr)
277
284
  return pr
278
285
  }
279
286
 
@@ -284,8 +291,8 @@ export const useGitHubStore = defineStore('github', () => {
284
291
  ) {
285
292
  await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
286
293
  // Optimistically reflect the merge until the next sync confirms it.
287
- const i = pulls.value.findIndex((p) => p.repoGithubId === repoGithubId && p.number === number)
288
- if (i >= 0) pulls.value[i] = { ...pulls.value[i]!, state: 'closed', merged: true }
294
+ const existing = getPull(pullKey(repoGithubId, number))
295
+ if (existing) upsertPull({ ...existing, state: 'closed', merged: true })
289
296
  }
290
297
 
291
298
  function comment(repoGithubId: number, number: number, body: string) {
@@ -4,6 +4,7 @@ import type { AgentKind, Pipeline } from '~/types/domain'
4
4
  import type { ConsensusStepConfig, StepGating } from '~/types/consensus'
5
5
  import type { StepOptions, TesterQualityConfig } from '@cat-factory/contracts'
6
6
  import { companionForProducer, uid } from '~/utils/catalog'
7
+ import { useUpsertList } from '~/composables/useUpsertList'
7
8
  import { useWorkspaceStore } from '~/stores/workspace'
8
9
 
9
10
  /** A sensible default config when a step is first flipped to consensus in the builder. */
@@ -31,7 +32,11 @@ function defaultConsensusConfig(): ConsensusStepConfig {
31
32
  */
32
33
  export const usePipelinesStore = defineStore('pipelines', () => {
33
34
  const api = useApi()
34
- const pipelines = ref<Pipeline[]>([])
35
+ const {
36
+ items: pipelines,
37
+ upsert: upsertPipeline,
38
+ remove: dropPipeline,
39
+ } = useUpsertList<Pipeline>({ key: (p) => p.id })
35
40
  /**
36
41
  * Current built-in catalog versions (`seedPipelines()`), keyed by pipeline id, from the
37
42
  * workspace snapshot. A built-in whose stored `version` is below its catalog value here has
@@ -369,13 +374,12 @@ export const usePipelinesStore = defineStore('pipelines', () => {
369
374
  const payload = draftPayload()
370
375
  if (editingId.value) {
371
376
  const updated = await api.updatePipeline(wsId, editingId.value, payload)
372
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
373
- if (i >= 0) pipelines.value[i] = updated
377
+ upsertPipeline(updated)
374
378
  clearDraft()
375
379
  return updated
376
380
  }
377
381
  const pipeline = await api.createPipeline(wsId, payload)
378
- pipelines.value.push(pipeline)
382
+ upsertPipeline(pipeline)
379
383
  clearDraft()
380
384
  return pipeline
381
385
  }
@@ -383,14 +387,14 @@ export const usePipelinesStore = defineStore('pipelines', () => {
383
387
  /** Clone any pipeline (built-in or custom) into an editable copy, ready to edit. */
384
388
  async function clonePipeline(id: string): Promise<Pipeline> {
385
389
  const clone = await api.clonePipeline(useWorkspaceStore().requireId(), id)
386
- pipelines.value.push(clone)
390
+ upsertPipeline(clone)
387
391
  loadForEdit(clone)
388
392
  return clone
389
393
  }
390
394
 
391
395
  async function removePipeline(id: string) {
392
396
  await api.removePipeline(useWorkspaceStore().requireId(), id)
393
- pipelines.value = pipelines.value.filter((p) => p.id !== id)
397
+ dropPipeline(id)
394
398
  if (editingId.value === id) clearDraft()
395
399
  }
396
400
 
@@ -401,8 +405,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
401
405
  */
402
406
  async function reseed(id: string): Promise<Pipeline> {
403
407
  const updated = await api.reseedPipeline(useWorkspaceStore().requireId(), id)
404
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
405
- if (i >= 0) pipelines.value[i] = updated
408
+ upsertPipeline(updated)
406
409
  if (editingId.value === id) clearDraft()
407
410
  return updated
408
411
  }
@@ -410,8 +413,7 @@ export const usePipelinesStore = defineStore('pipelines', () => {
410
413
  /** Set a pipeline's organizational metadata (labels / archive). Works on built-ins too. */
411
414
  async function organize(id: string, body: { labels?: string[]; archived?: boolean }) {
412
415
  const updated = await api.organizePipeline(useWorkspaceStore().requireId(), id, body)
413
- const i = pipelines.value.findIndex((p) => p.id === updated.id)
414
- if (i >= 0) pipelines.value[i] = updated
416
+ upsertPipeline(updated)
415
417
  return updated
416
418
  }
417
419
 
@@ -10,6 +10,7 @@ import type {
10
10
  IncidentEnrichmentView,
11
11
  UpsertIncidentEnrichmentInput,
12
12
  } from '~/types/incidentEnrichment'
13
+ import { useUpsertList } from '~/composables/useUpsertList'
13
14
  import { useWorkspaceStore } from '~/stores/workspace'
14
15
 
15
16
  /**
@@ -26,7 +27,11 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
26
27
  provider: null,
27
28
  summary: null,
28
29
  })
29
- const configs = ref<ReleaseHealthConfig[]>([])
30
+ const {
31
+ items: configs,
32
+ upsert: upsertConfig,
33
+ remove: dropConfig,
34
+ } = useUpsertList<ReleaseHealthConfig>({ key: (c) => c.blockId })
30
35
  // Incident-enrichment (PagerDuty + incident.io) connection — write-only secrets, the
31
36
  // store only ever holds the presence summary. Wired alongside observability.
32
37
  const incident = ref<IncidentEnrichmentView>({ connected: false, summary: null })
@@ -91,16 +96,14 @@ export const useReleaseHealthStore = defineStore('releaseHealth', () => {
91
96
  async function saveConfig(blockId: string, input: UpsertReleaseHealthConfigInput) {
92
97
  const ws = useWorkspaceStore()
93
98
  const saved = await api.upsertReleaseHealthConfig(ws.requireId(), blockId, input)
94
- const idx = configs.value.findIndex((c) => c.blockId === blockId)
95
- if (idx >= 0) configs.value[idx] = saved
96
- else configs.value.push(saved)
99
+ upsertConfig(saved)
97
100
  return saved
98
101
  }
99
102
 
100
103
  async function removeConfig(blockId: string) {
101
104
  const ws = useWorkspaceStore()
102
105
  await api.deleteReleaseHealthConfig(ws.requireId(), blockId)
103
- configs.value = configs.value.filter((c) => c.blockId !== blockId)
106
+ dropConfig(blockId)
104
107
  }
105
108
 
106
109
  /** Load the incident-enrichment connection (separate opt-in gate from observability). */
@@ -1,10 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
3
2
  import type {
4
3
  DetectSharedStackInput,
5
4
  SharedStack,
6
5
  UpdateSharedStackInput,
7
6
  } from '~/types/sharedStacks'
7
+ import { useUpsertList } from '~/composables/useUpsertList'
8
8
  import { useWorkspaceStore } from '~/stores/workspace'
9
9
 
10
10
  /**
@@ -19,18 +19,13 @@ import { useWorkspaceStore } from '~/stores/workspace'
19
19
  */
20
20
  export const useSharedStacksStore = defineStore('sharedStacks', () => {
21
21
  const api = useApi()
22
- const stacks = ref<SharedStack[]>([])
22
+ const { items: stacks, upsert: patch } = useUpsertList<SharedStack>({ key: (s) => s.id })
23
23
 
24
24
  function hydrate(list: SharedStack[]) {
25
+ // Keep the snapshot sorted oldest-first; the helper's plain hydrate wouldn't sort.
25
26
  stacks.value = [...list].sort((a, b) => a.createdAt - b.createdAt)
26
27
  }
27
28
 
28
- function patch(stack: SharedStack) {
29
- const idx = stacks.value.findIndex((s) => s.id === stack.id)
30
- if (idx >= 0) stacks.value[idx] = stack
31
- else stacks.value.push(stack)
32
- }
33
-
34
29
  async function create(input: Parameters<typeof api.createSharedStack>[1]) {
35
30
  const ws = useWorkspaceStore()
36
31
  const created = await api.createSharedStack(ws.requireId(), input)