@cat-factory/app 0.33.0 → 0.35.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.
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ // Local-mode-only settings: the warm-container pool + per-repo checkout reuse. These are
3
+ // a per-DEPLOYMENT singleton stored in the DB (they replaced the LOCAL_POOL_* / HARNESS_*
4
+ // env vars), so a developer tunes them here instead of editing .env. The warm pool keeps
5
+ // idle harness containers ready and re-leases one (preferring repo affinity) to each run —
6
+ // far faster startup than a cold container per run. Saving applies the new sizing to the
7
+ // running service immediately (the pool is resized live — no restart needed); in-flight
8
+ // runs keep the container they already hold, and the checkout config applies to containers
9
+ // started after the save.
10
+ import { reactive, ref, watch } from 'vue'
11
+
12
+ const ui = useUiStore()
13
+ const store = useLocalSettingsStore()
14
+ const toast = useToast()
15
+
16
+ const open = computed({
17
+ get: () => ui.localModeSettingsOpen,
18
+ set: (v: boolean) => (v ? ui.openLocalModeSettings() : ui.closeLocalModeSettings()),
19
+ })
20
+
21
+ const saving = ref(false)
22
+
23
+ // Editable draft. `idleMinutes` and `cleanKeep` are friendlier renderings of the stored
24
+ // `pool.idleTtlMs` (ms) and `checkout.cleanKeep` (string[]).
25
+ const draft = reactive({
26
+ size: 0,
27
+ minWarm: 0,
28
+ max: null as number | null,
29
+ idleMinutes: 10,
30
+ workspaceRoot: '/workspace',
31
+ cleanKeep: 'node_modules,.venv,target,.gradle,.pnpm-store',
32
+ })
33
+
34
+ function syncDraft() {
35
+ const s = store.settings
36
+ if (!s) return
37
+ draft.size = s.pool.size
38
+ draft.minWarm = s.pool.minWarm
39
+ draft.max = s.pool.max
40
+ draft.idleMinutes = Math.round(s.pool.idleTtlMs / 60_000)
41
+ draft.workspaceRoot = s.checkout.workspaceRoot
42
+ draft.cleanKeep = s.checkout.cleanKeep.join(',')
43
+ }
44
+
45
+ // Load + hydrate the draft whenever the panel opens.
46
+ watch(open, (isOpen) => {
47
+ if (isOpen) void store.load().then(syncDraft)
48
+ })
49
+ watch(() => store.settings, syncDraft)
50
+
51
+ async function save() {
52
+ const cleanKeep = draft.cleanKeep
53
+ .split(',')
54
+ .map((s) => s.trim())
55
+ .filter(Boolean)
56
+ saving.value = true
57
+ try {
58
+ await store.save({
59
+ pool: {
60
+ size: Math.max(0, Math.floor(draft.size)),
61
+ minWarm: Math.max(0, Math.floor(draft.minWarm)),
62
+ max: draft.max == null ? null : Math.max(0, Math.floor(draft.max)),
63
+ idleTtlMs: Math.max(0, Math.floor(draft.idleMinutes * 60_000)),
64
+ },
65
+ checkout: { workspaceRoot: draft.workspaceRoot.trim() || '/workspace', cleanKeep },
66
+ })
67
+ toast.add({ title: 'Local settings saved', icon: 'i-lucide-check', color: 'success' })
68
+ } catch (e) {
69
+ toast.add({
70
+ title: 'Could not save local settings',
71
+ description: e instanceof Error ? e.message : String(e),
72
+ icon: 'i-lucide-triangle-alert',
73
+ color: 'error',
74
+ })
75
+ } finally {
76
+ saving.value = false
77
+ }
78
+ }
79
+ </script>
80
+
81
+ <template>
82
+ <UModal v-model:open="open" title="Local mode" :ui="{ content: 'max-w-2xl' }">
83
+ <template #body>
84
+ <div class="space-y-6">
85
+ <p class="text-xs text-slate-400">
86
+ Tuning for the local container runner — stored on this machine's deployment (it replaced
87
+ the <code>LOCAL_POOL_*</code> / <code>HARNESS_*</code> env vars). Saving resizes the warm
88
+ pool live — no restart needed; in-flight runs keep the container they already hold.
89
+ </p>
90
+
91
+ <!-- Warm container pool -->
92
+ <section class="space-y-3">
93
+ <div>
94
+ <h4 class="text-sm font-semibold text-slate-200">Warm container pool</h4>
95
+ <p class="text-[11px] text-slate-400">
96
+ Keep idle harness containers ready and re-lease one (preferring a container that
97
+ already holds the run's repo) instead of cold-starting per run. Pool size 0 disables
98
+ it. Requires a Docker-family runtime (Docker/Podman/OrbStack/Colima); ignored on Apple
99
+ <code>container</code>.
100
+ </p>
101
+ </div>
102
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
103
+ <UFormField label="Pool size" help="Max idle warm containers. 0 = pooling off.">
104
+ <UInput v-model.number="draft.size" type="number" :min="0" size="sm" />
105
+ </UFormField>
106
+ <UFormField label="Pre-warm at boot" help="Containers started when the service boots.">
107
+ <UInput v-model.number="draft.minWarm" type="number" :min="0" size="sm" />
108
+ </UFormField>
109
+ <UFormField label="Max containers" help="Hard cap (leased + idle). Blank = pool size.">
110
+ <UInput
111
+ v-model.number="draft.max"
112
+ type="number"
113
+ :min="0"
114
+ size="sm"
115
+ placeholder="(pool size)"
116
+ />
117
+ </UFormField>
118
+ <UFormField label="Idle timeout (minutes)" help="Evict an idle pooled container after.">
119
+ <UInput v-model.number="draft.idleMinutes" type="number" :min="0" size="sm" />
120
+ </UFormField>
121
+ </div>
122
+ </section>
123
+
124
+ <!-- Checkout reuse -->
125
+ <section class="space-y-3 border-t border-slate-800 pt-6">
126
+ <div>
127
+ <h4 class="text-sm font-semibold text-slate-200">Checkout reuse</h4>
128
+ <p class="text-[11px] text-slate-400">
129
+ When a warm container already holds the run's repo, the harness reuses its per-repo
130
+ checkout (clean sweep + fetch + switch branch) instead of cloning fresh.
131
+ </p>
132
+ </div>
133
+ <UFormField
134
+ label="Workspace root"
135
+ help="Absolute in-container directory the reused checkout lives under."
136
+ >
137
+ <UInput v-model="draft.workspaceRoot" size="sm" placeholder="/workspace" />
138
+ </UFormField>
139
+ <UFormField
140
+ label="Keep on clean (comma-separated)"
141
+ help="Dependency-cache directories the per-run clean sweep preserves."
142
+ >
143
+ <UInput
144
+ v-model="draft.cleanKeep"
145
+ size="sm"
146
+ placeholder="node_modules,.venv,target,.gradle,.pnpm-store"
147
+ />
148
+ </UFormField>
149
+ </section>
150
+
151
+ <div class="flex justify-end">
152
+ <UButton color="primary" icon="i-lucide-save" :loading="saving" @click="save">
153
+ Save
154
+ </UButton>
155
+ </div>
156
+ </div>
157
+ </template>
158
+ </UModal>
159
+ </template>
@@ -8,6 +8,7 @@ import { onMounted, ref } from 'vue'
8
8
 
9
9
  const fragments = useFragmentsStore()
10
10
  const defaults = useServiceFragmentDefaultsStore()
11
+ const ui = useUiStore()
11
12
  const toast = useToast()
12
13
 
13
14
  const busy = ref(false)
@@ -105,5 +106,25 @@ function remove(id: string) {
105
106
  <p v-else class="text-[11px] text-slate-500">
106
107
  None — new services start with no service-level fragments.
107
108
  </p>
109
+
110
+ <div class="flex flex-wrap gap-x-4 gap-y-1 border-t border-slate-800 pt-3 text-[11px]">
111
+ <span class="text-slate-500">
112
+ Need to add or edit the fragments themselves (hand-authored, linked docs, repos)?
113
+ </span>
114
+ <button
115
+ type="button"
116
+ class="font-medium text-primary-400 hover:underline"
117
+ @click="ui.openFragmentLibrary()"
118
+ >
119
+ Manage this board's fragment library →
120
+ </button>
121
+ <button
122
+ type="button"
123
+ class="font-medium text-primary-400 hover:underline"
124
+ @click="ui.openAccountSettings('fragments')"
125
+ >
126
+ Manage account fragments →
127
+ </button>
128
+ </div>
108
129
  </div>
109
130
  </template>
@@ -53,10 +53,19 @@ export function fragmentsApi({ http, ws, scope }: ApiContext) {
53
53
  body: CreateDocumentFragmentInput,
54
54
  ) => http<PromptFragment>(`${scope(kind, id)}/document-fragments`, { method: 'POST', body }),
55
55
 
56
- // Force an immediate live re-resolve of a document-backed fragment.
57
- refreshFragment: (kind: FragmentOwnerKind, id: string, fragmentId: string) =>
56
+ // Force an immediate live re-resolve of a document-backed fragment. At the
57
+ // account scope the backend needs a `viaWorkspaceId` (the workspace whose
58
+ // document-source connection to fetch through); it is ignored at workspace scope.
59
+ refreshFragment: (
60
+ kind: FragmentOwnerKind,
61
+ id: string,
62
+ fragmentId: string,
63
+ viaWorkspaceId?: string,
64
+ ) =>
58
65
  http<PromptFragment>(
59
- `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh`,
66
+ `${scope(kind, id)}/prompt-fragments/${encodeURIComponent(fragmentId)}/refresh${
67
+ viaWorkspaceId ? `?viaWorkspaceId=${encodeURIComponent(viaWorkspaceId)}` : ''
68
+ }`,
60
69
  { method: 'POST' },
61
70
  ),
62
71
 
@@ -0,0 +1,17 @@
1
+ import type { LocalSettings, UpdateLocalSettingsInput } from '~/types/localSettings'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * Local-mode operational settings (warm-container-pool sizing + per-repo checkout reuse) —
6
+ * a per-deployment singleton. Wired only on the local-mode service; both calls 503 on the
7
+ * Worker / stock Node facades (the store hides the panel then). No secrets, so the read view
8
+ * is the plain config and the write replaces it wholesale.
9
+ */
10
+ export function localSettingsApi({ http }: ApiContext) {
11
+ return {
12
+ getLocalSettings: () => http<LocalSettings>('/local-settings'),
13
+
14
+ updateLocalSettings: (body: UpdateLocalSettingsInput) =>
15
+ http<LocalSettings>('/local-settings', { method: 'PUT', body }),
16
+ }
17
+ }
@@ -11,6 +11,7 @@ import { fragmentsApi } from './api/fragments'
11
11
  import { githubApi } from './api/github'
12
12
  import { humanTestApi } from './api/humanTest'
13
13
  import { kaizenApi } from './api/kaizen'
14
+ import { localSettingsApi } from './api/localSettings'
14
15
  import { modelsApi } from './api/models'
15
16
  import { notificationsApi } from './api/notifications'
16
17
  import { presetsApi } from './api/presets'
@@ -89,6 +90,7 @@ export function useApi() {
89
90
  ...followUpsApi(ctx),
90
91
  ...humanTestApi(ctx),
91
92
  ...kaizenApi(ctx),
93
+ ...localSettingsApi(ctx),
92
94
  ...specApi(ctx),
93
95
  ...notificationsApi(ctx),
94
96
  ...presetsApi(ctx),
@@ -35,6 +35,7 @@ import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPan
35
35
  import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
36
36
  import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
37
37
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
38
+ import LocalModeSettingsPanel from '~/components/settings/LocalModeSettingsPanel.vue'
38
39
  import SandboxPanel from '~/components/sandbox/SandboxPanel.vue'
39
40
  import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
40
41
  import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
@@ -194,6 +195,7 @@ watch(
194
195
  <ProviderConnectionPanel />
195
196
  <ModelConfigurationPanel />
196
197
  <LocalModelEndpointsPanel />
198
+ <LocalModeSettingsPanel />
197
199
  <SandboxPanel />
198
200
  <UserSecretsSection />
199
201
  <OpenRouterCatalogPanel />
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
3
3
  import type {
4
4
  CreateDocumentFragmentInput,
5
5
  CreatePromptFragmentInput,
6
+ FragmentOwnerKind,
6
7
  FragmentSource,
7
8
  LinkFragmentSourceInput,
8
9
  PromptFragment,
@@ -12,40 +13,62 @@ import type {
12
13
  import { useWorkspaceStore } from '~/stores/workspace'
13
14
 
14
15
  /**
15
- * Prompt-fragment library state (ADR 0006), scoped to the active board. Holds the
16
- * board's own (workspace-tier) fragments, its linked guideline repos, and the
17
- * merged catalog an agent actually sees (built-in account workspace). The
18
- * management surface targets the workspace tier; the resolved read is what every
19
- * agent run is selected from. `available` mirrors the backend's opt-in gate: a
20
- * 503 from the resolve probe means the feature is off and the UI hides its entry.
16
+ * Prompt-fragment library state (ADR 0006), scoped to a single owner a board
17
+ * (`workspace`) or an account. Holds that owner's own (raw) tier fragments, its
18
+ * linked guideline repos, and for the **workspace** tier only the merged
19
+ * catalog an agent actually sees (built-in account workspace). `available`
20
+ * mirrors the backend's opt-in gate: a 503 from the probe means the feature is off
21
+ * and the UI hides its entry.
22
+ *
23
+ * Two entry points share this setup: `useFragmentLibraryStore` (the workspace
24
+ * singleton that follows the active board, used by the navbar + the board modal)
25
+ * and `useFragmentLibrary(kind, ownerId)` (an owner-keyed store, used for the
26
+ * account tier). The account tier has no resolved/merged catalog and, for
27
+ * document-backed fragments, needs a `viaWorkspaceId` (document-source credentials
28
+ * are per-workspace).
21
29
  */
22
- export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
30
+ function fragmentLibrarySetup(kind: FragmentOwnerKind, resolveOwnerId: () => string | null) {
23
31
  const api = useApi()
24
- const workspace = useWorkspaceStore()
32
+
33
+ /** The merged/resolved catalog only exists at the workspace tier. */
34
+ const hasResolved = kind === 'workspace'
25
35
 
26
36
  /** null = not probed yet; true/false = library on/off. */
27
37
  const available = ref<boolean | null>(null)
28
- /** This board's hand-authored + sourced fragments (workspace tier, raw). */
38
+ /** This owner's hand-authored + sourced fragments (its own tier, raw). */
29
39
  const fragments = ref<PromptFragment[]>([])
30
- /** The merged catalog an agent sees (with each entry's winning tier). */
40
+ /** The merged catalog an agent sees (workspace tier only; empty otherwise). */
31
41
  const resolved = ref<ResolvedFragment[]>([])
32
- /** Linked guideline repos for this board. */
42
+ /** Linked guideline repos for this owner. */
33
43
  const sources = ref<FragmentSource[]>([])
34
44
  /** Per-source "changes available" counts from the last status check. */
35
45
  const sourceChanges = ref<Record<string, number>>({})
36
46
  const loading = ref(false)
47
+ /**
48
+ * Account-tier document fragments only: the workspace whose stored
49
+ * document-source connection is used to fetch/refresh the page (credentials are
50
+ * per-workspace). Set by the caller; ignored at the workspace scope (the owner
51
+ * board is used directly).
52
+ */
53
+ const viaWorkspaceId = ref<string | undefined>(undefined)
37
54
 
38
55
  const builtinCount = computed(() => resolved.value.filter((f) => f.tier === 'builtin').length)
39
56
 
40
- /** Probe the feature + load this board's tier, sources and resolved catalog. */
57
+ function requireOwnerId(): string {
58
+ const id = resolveOwnerId()
59
+ if (!id) throw new Error('No fragment-library owner')
60
+ return id
61
+ }
62
+
63
+ /** Probe the feature + load this owner's tier, sources and (ws) resolved catalog. */
41
64
  async function probe() {
42
- if (!workspace.workspaceId) return
43
- const id = workspace.requireId()
65
+ const id = resolveOwnerId()
66
+ if (!id) return
44
67
  try {
45
68
  const [tier, srcs, merged] = await Promise.all([
46
- api.listFragments('workspace', id),
47
- api.listFragmentSources('workspace', id).catch(() => [] as FragmentSource[]),
48
- api.getResolvedFragments(id),
69
+ api.listFragments(kind, id),
70
+ api.listFragmentSources(kind, id).catch(() => [] as FragmentSource[]),
71
+ hasResolved ? api.getResolvedFragments(id) : Promise.resolve([] as ResolvedFragment[]),
49
72
  ])
50
73
  fragments.value = tier
51
74
  sources.value = srcs
@@ -60,13 +83,14 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
60
83
  }
61
84
 
62
85
  async function refreshResolved() {
63
- resolved.value = await api.getResolvedFragments(workspace.requireId())
86
+ if (!hasResolved) return
87
+ resolved.value = await api.getResolvedFragments(requireOwnerId())
64
88
  }
65
89
 
66
90
  async function create(input: CreatePromptFragmentInput) {
67
91
  loading.value = true
68
92
  try {
69
- await api.createFragment('workspace', workspace.requireId(), input)
93
+ await api.createFragment(kind, requireOwnerId(), input)
70
94
  await Promise.all([reloadTier(), refreshResolved()])
71
95
  } finally {
72
96
  loading.value = false
@@ -74,7 +98,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
74
98
  }
75
99
 
76
100
  async function update(fragmentId: string, patch: UpdatePromptFragmentInput) {
77
- await api.updateFragment('workspace', workspace.requireId(), fragmentId, patch)
101
+ await api.updateFragment(kind, requireOwnerId(), fragmentId, patch)
78
102
  await Promise.all([reloadTier(), refreshResolved()])
79
103
  }
80
104
 
@@ -82,7 +106,10 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
82
106
  async function createDocumentFragment(input: CreateDocumentFragmentInput) {
83
107
  loading.value = true
84
108
  try {
85
- await api.createDocumentFragment('workspace', workspace.requireId(), input)
109
+ await api.createDocumentFragment(kind, requireOwnerId(), {
110
+ ...input,
111
+ ...(viaWorkspaceId.value ? { viaWorkspaceId: viaWorkspaceId.value } : {}),
112
+ })
86
113
  await Promise.all([reloadTier(), refreshResolved()])
87
114
  } finally {
88
115
  loading.value = false
@@ -93,31 +120,31 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
93
120
  async function refreshDocumentFragment(fragmentId: string) {
94
121
  loading.value = true
95
122
  try {
96
- await api.refreshFragment('workspace', workspace.requireId(), fragmentId)
123
+ await api.refreshFragment(kind, requireOwnerId(), fragmentId, viaWorkspaceId.value)
97
124
  await Promise.all([reloadTier(), refreshResolved()])
98
125
  } finally {
99
126
  loading.value = false
100
127
  }
101
128
  }
102
129
 
103
- /** Tombstone a fragment at the workspace tier (suppresses an inherited one). */
130
+ /** Tombstone a fragment at this tier (suppresses an inherited one). */
104
131
  async function remove(fragmentId: string) {
105
- await api.deleteFragment('workspace', workspace.requireId(), fragmentId)
132
+ await api.deleteFragment(kind, requireOwnerId(), fragmentId)
106
133
  await Promise.all([reloadTier(), refreshResolved()])
107
134
  }
108
135
 
109
136
  async function reloadTier() {
110
- fragments.value = await api.listFragments('workspace', workspace.requireId())
137
+ fragments.value = await api.listFragments(kind, requireOwnerId())
111
138
  }
112
139
 
113
140
  async function linkSource(input: LinkFragmentSourceInput) {
114
- const source = await api.linkFragmentSource('workspace', workspace.requireId(), input)
141
+ const source = await api.linkFragmentSource(kind, requireOwnerId(), input)
115
142
  sources.value = [source, ...sources.value]
116
143
  return source
117
144
  }
118
145
 
119
146
  async function unlinkSource(sourceId: string) {
120
- await api.unlinkFragmentSource('workspace', workspace.requireId(), sourceId)
147
+ await api.unlinkFragmentSource(kind, requireOwnerId(), sourceId)
121
148
  sources.value = sources.value.filter((s) => s.id !== sourceId)
122
149
  await refreshResolved()
123
150
  }
@@ -126,7 +153,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
126
153
  async function syncSource(sourceId: string) {
127
154
  loading.value = true
128
155
  try {
129
- const result = await api.syncFragmentSource('workspace', workspace.requireId(), sourceId)
156
+ const result = await api.syncFragmentSource(kind, requireOwnerId(), sourceId)
130
157
  delete sourceChanges.value[sourceId]
131
158
  await Promise.all([reloadSources(), refreshResolved()])
132
159
  return result
@@ -137,7 +164,7 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
137
164
 
138
165
  /** Cheap "check for changes" for a source; caches the changed count. */
139
166
  async function checkSource(sourceId: string) {
140
- const status = await api.fragmentSourceStatus('workspace', workspace.requireId(), sourceId)
167
+ const status = await api.fragmentSourceStatus(kind, requireOwnerId(), sourceId)
141
168
  sourceChanges.value = {
142
169
  ...sourceChanges.value,
143
170
  [sourceId]: status.changed ? status.changedCount : 0,
@@ -146,16 +173,19 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
146
173
  }
147
174
 
148
175
  async function reloadSources() {
149
- sources.value = await api.listFragmentSources('workspace', workspace.requireId())
176
+ sources.value = await api.listFragmentSources(kind, requireOwnerId())
150
177
  }
151
178
 
152
179
  return {
180
+ kind,
181
+ hasResolved,
153
182
  available,
154
183
  fragments,
155
184
  resolved,
156
185
  sources,
157
186
  sourceChanges,
158
187
  loading,
188
+ viaWorkspaceId,
159
189
  builtinCount,
160
190
  probe,
161
191
  refreshResolved,
@@ -169,4 +199,23 @@ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () => {
169
199
  syncSource,
170
200
  checkSource,
171
201
  }
172
- })
202
+ }
203
+
204
+ /**
205
+ * The workspace-tier library for the **active** board — a singleton that resolves
206
+ * the owner lazily, so it follows board switches and is shared by the navbar
207
+ * (SideBar/CommandBar probes) and the board fragment modal.
208
+ */
209
+ export const useFragmentLibraryStore = defineStore('fragmentLibrary', () =>
210
+ fragmentLibrarySetup('workspace', () => useWorkspaceStore().workspaceId),
211
+ )
212
+
213
+ /**
214
+ * An owner-keyed library store, used for the **account** tier (and reusable for any
215
+ * explicit owner). Keyed by `(kind, ownerId)` so each account gets isolated state.
216
+ */
217
+ export function useFragmentLibrary(kind: FragmentOwnerKind, ownerId: string) {
218
+ return defineStore(`fragmentLibrary:${kind}:${ownerId}`, () =>
219
+ fragmentLibrarySetup(kind, () => ownerId),
220
+ )()
221
+ }
@@ -0,0 +1,48 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { LocalSettings, UpdateLocalSettingsInput } from '~/types/localSettings'
4
+
5
+ /**
6
+ * Local-mode operational settings (warm-container-pool sizing + per-repo checkout reuse) —
7
+ * a per-deployment singleton that replaced the old LOCAL_POOL_* / HARNESS_* env vars. No
8
+ * secrets, so the store holds the full config. `available` mirrors the backend opt-in: a 503
9
+ * (not the local-mode service) hides the panel. Loaded on demand from the settings panel.
10
+ */
11
+ export const useLocalSettingsStore = defineStore('localSettings', () => {
12
+ const api = useApi()
13
+
14
+ const settings = ref<LocalSettings | null>(null)
15
+ const loading = ref(false)
16
+ const available = ref<boolean | null>(null)
17
+
18
+ async function load() {
19
+ loading.value = true
20
+ try {
21
+ settings.value = await api.getLocalSettings()
22
+ available.value = true
23
+ } catch (e) {
24
+ // 503 ⇒ not the local-mode service ⇒ hide the panel.
25
+ if (
26
+ e &&
27
+ typeof e === 'object' &&
28
+ 'statusCode' in e &&
29
+ (e as { statusCode?: number }).statusCode === 503
30
+ ) {
31
+ available.value = false
32
+ settings.value = null
33
+ } else {
34
+ throw e
35
+ }
36
+ } finally {
37
+ loading.value = false
38
+ }
39
+ }
40
+
41
+ async function save(input: UpdateLocalSettingsInput) {
42
+ settings.value = await api.updateLocalSettings(input)
43
+ available.value = true
44
+ return settings.value
45
+ }
46
+
47
+ return { settings, loading, available, load, save }
48
+ })
package/app/stores/ui.ts CHANGED
@@ -96,9 +96,13 @@ export const useUiStore = defineStore('ui', () => {
96
96
  // `workspaceSettingsTab` lets other surfaces deep-link straight to a tab.
97
97
  const workspaceSettingsOpen = ref(false)
98
98
  const workspaceSettingsTab = ref('workspace')
99
- // Account/team-settings modal: the per-account members + roles + invitations + email
100
- // sender panel (`AccountTeamSettings`). Account-scoped (distinct from workspace settings).
99
+ // Account-settings modal: a single tabbed window for the per-account configuration
100
+ // the team panel (members + roles + invitations + email sender + account API keys,
101
+ // `AccountTeamSettings`) and the account-tier prompt-fragment library. Account-scoped
102
+ // (distinct from workspace settings). `accountSettingsTab` lets other surfaces deep-link
103
+ // straight to a tab.
101
104
  const accountSettingsOpen = ref(false)
105
+ const accountSettingsTab = ref('team')
102
106
  // Observability integration: the post-release-health connection panel (Datadog
103
107
  // today, pluggable). NB: distinct from `observabilityInstanceId` below, which is the
104
108
  // LLM per-call observability panel.
@@ -112,6 +116,9 @@ export const useUiStore = defineStore('ui', () => {
112
116
  const vendorCredentialsOpen = ref(false)
113
117
  // Per-user settings panel: the signed-in user's own-machine local model runners.
114
118
  const localModelsOpen = ref(false)
119
+ // Local-mode-only settings panel: the warm-container pool sizing + per-repo checkout reuse
120
+ // (a per-deployment singleton that replaced the LOCAL_POOL_* / HARNESS_* env vars).
121
+ const localModeSettingsOpen = ref(false)
115
122
  // The Sandbox (parallel prompt/model testing) surface — an opt-in, on-demand window.
116
123
  const sandboxOpen = ref(false)
117
124
  const userSecretsOpen = ref(false)
@@ -383,13 +390,17 @@ export const useUiStore = defineStore('ui', () => {
383
390
  function setWorkspaceSettingsTab(tab: string) {
384
391
  workspaceSettingsTab.value = tab
385
392
  }
386
- function openAccountSettings() {
393
+ function openAccountSettings(tab = 'team') {
387
394
  cameFromIntegrations.value = false
395
+ accountSettingsTab.value = tab
388
396
  accountSettingsOpen.value = true
389
397
  }
390
398
  function closeAccountSettings() {
391
399
  accountSettingsOpen.value = false
392
400
  }
401
+ function setAccountSettingsTab(tab: string) {
402
+ accountSettingsTab.value = tab
403
+ }
393
404
  function openObservabilityConnection() {
394
405
  cameFromIntegrations.value = false
395
406
  observabilityConnectionOpen.value = true
@@ -424,6 +435,13 @@ export const useUiStore = defineStore('ui', () => {
424
435
  function closeLocalModels() {
425
436
  localModelsOpen.value = false
426
437
  }
438
+ function openLocalModeSettings() {
439
+ cameFromIntegrations.value = false
440
+ localModeSettingsOpen.value = true
441
+ }
442
+ function closeLocalModeSettings() {
443
+ localModeSettingsOpen.value = false
444
+ }
427
445
  function openSandbox() {
428
446
  sandboxOpen.value = true
429
447
  }
@@ -566,11 +584,13 @@ export const useUiStore = defineStore('ui', () => {
566
584
  workspaceSettingsOpen,
567
585
  workspaceSettingsTab,
568
586
  accountSettingsOpen,
587
+ accountSettingsTab,
569
588
  observabilityConnectionOpen,
570
589
  providerConnectionKind,
571
590
  modelConfigOpen,
572
591
  vendorCredentialsOpen,
573
592
  localModelsOpen,
593
+ localModeSettingsOpen,
574
594
  sandboxOpen,
575
595
  userSecretsOpen,
576
596
  openRouterOpen,
@@ -630,6 +650,7 @@ export const useUiStore = defineStore('ui', () => {
630
650
  setWorkspaceSettingsTab,
631
651
  openAccountSettings,
632
652
  closeAccountSettings,
653
+ setAccountSettingsTab,
633
654
  openObservabilityConnection,
634
655
  closeObservabilityConnection,
635
656
  openProviderConnection,
@@ -640,6 +661,8 @@ export const useUiStore = defineStore('ui', () => {
640
661
  closeVendorCredentials,
641
662
  openLocalModels,
642
663
  closeLocalModels,
664
+ openLocalModeSettings,
665
+ closeLocalModeSettings,
643
666
  openSandbox,
644
667
  closeSandbox,
645
668
  openUserSecrets,
@@ -0,0 +1,31 @@
1
+ // Local-mode operational settings (warm-container-pool sizing + per-repo checkout reuse).
2
+ // Mirrors `@cat-factory/contracts` localSettings. A per-deployment singleton, edited in the
3
+ // dedicated local-mode settings panel — these replaced the old LOCAL_POOL_* / HARNESS_* env
4
+ // vars. There are no secrets, so the read view is the plain config. Local-mode-only (the
5
+ // warm pool is the local Docker-family runner's differentiator).
6
+
7
+ export interface LocalPoolSettings {
8
+ /** Max idle warm containers kept for re-lease. 0 disables pooling (cold-start per run). */
9
+ size: number
10
+ /** Containers pre-warmed when the service starts. */
11
+ minWarm: number
12
+ /** Hard cap on total containers (leased + idle). `null` ⇒ defaults to `size`. */
13
+ max: number | null
14
+ /** How long an idle pooled container is kept before eviction (ms). */
15
+ idleTtlMs: number
16
+ }
17
+
18
+ export interface LocalCheckoutSettings {
19
+ /** Absolute in-container dir the reused per-repo checkout lives under. */
20
+ workspaceRoot: string
21
+ /** Dep-cache directories the per-run clean sweep keeps (so deps aren't reinstalled). */
22
+ cleanKeep: string[]
23
+ }
24
+
25
+ export interface LocalSettings {
26
+ pool: LocalPoolSettings
27
+ checkout: LocalCheckoutSettings
28
+ }
29
+
30
+ /** Admin write: the full settings blob fully replaces the stored config. */
31
+ export type UpdateLocalSettingsInput = LocalSettings