@cat-factory/app 0.199.0 → 0.200.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.
- package/app/components/foundational/FoundationalContractSummary.vue +36 -0
- package/app/components/foundational/FoundationalServiceCatalogList.vue +170 -0
- package/app/components/foundational/FoundationalServiceManager.vue +111 -0
- package/app/components/foundational/FoundationalServicePanel.vue +37 -0
- package/app/components/foundational/FoundationalServiceRegistry.vue +339 -0
- package/app/components/foundational/FoundationalServiceSources.vue +398 -0
- package/app/components/foundational/FoundationalSuppressions.vue +75 -0
- package/app/components/layout/AccountFoundationalSettings.vue +25 -0
- package/app/components/settings/AccountSettingsPanel.vue +17 -1
- package/app/composables/api/foundationalServices.ts +131 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/modular/nav-contributions.spec.ts +6 -0
- package/app/modular/nav-contributions.ts +26 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/foundationalServices.spec.ts +121 -0
- package/app/stores/foundationalServices.ts +276 -0
- package/app/stores/ui/modals.ts +12 -0
- package/app/types/domain.ts +1 -0
- package/app/types/foundationalServices.ts +32 -0
- package/i18n/locales/de.json +144 -6
- package/i18n/locales/en.json +144 -6
- package/i18n/locales/es.json +144 -6
- package/i18n/locales/fr.json +144 -6
- package/i18n/locales/he.json +144 -6
- package/i18n/locales/it.json +144 -6
- package/i18n/locales/ja.json +144 -6
- package/i18n/locales/pl.json +144 -6
- package/i18n/locales/tr.json +144 -6
- package/i18n/locales/uk.json +144 -6
- package/package.json +2 -2
|
@@ -49,6 +49,7 @@ export function useNavContributions() {
|
|
|
49
49
|
kaizen: () => ui.openKaizen(),
|
|
50
50
|
infrastructure: () => ui.openInfrastructure(),
|
|
51
51
|
fragmentLibrary: () => ui.openFragmentLibrary(),
|
|
52
|
+
foundationalServices: () => ui.openFoundationalServices(),
|
|
52
53
|
mergeThresholds: () => ui.openWorkspaceSettings('merge'),
|
|
53
54
|
workspaceSettings: () => ui.openWorkspaceSettings(),
|
|
54
55
|
modelConfiguration: () => ui.openModelConfig(),
|
|
@@ -224,6 +224,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
224
224
|
titleKey: 'errors.conflict.title.foundational_service_exists',
|
|
225
225
|
descriptionKey: 'errors.conflict.description.foundational_service_exists',
|
|
226
226
|
},
|
|
227
|
+
foundational_service_not_inherited: {
|
|
228
|
+
titleKey: 'errors.conflict.title.foundational_service_not_inherited',
|
|
229
|
+
descriptionKey: 'errors.conflict.description.foundational_service_not_inherited',
|
|
230
|
+
},
|
|
227
231
|
pipeline_schedule_intake_unconfigured: {
|
|
228
232
|
titleKey: 'errors.conflict.title.pipeline_schedule_intake_unconfigured',
|
|
229
233
|
descriptionKey: 'errors.conflict.description.pipeline_schedule_intake_unconfigured',
|
|
@@ -124,6 +124,7 @@ describe('navSlotFilter', () => {
|
|
|
124
124
|
expect(kept).not.toContain('bootstrap-repo')
|
|
125
125
|
expect(kept).not.toContain('operator-dashboard')
|
|
126
126
|
expect(kept).not.toContain('reports')
|
|
127
|
+
expect(kept).not.toContain('foundational-services')
|
|
127
128
|
})
|
|
128
129
|
|
|
129
130
|
it('states, per advanced item, whether basic mode still reaches its capability', () => {
|
|
@@ -166,6 +167,10 @@ describe('navSlotFilter', () => {
|
|
|
166
167
|
kind: 'out-of-tier',
|
|
167
168
|
why: 'deployment-wide spend/activity rollup - an operator job, not a delivery one',
|
|
168
169
|
},
|
|
170
|
+
'foundational-services': {
|
|
171
|
+
kind: 'out-of-tier',
|
|
172
|
+
why: 'org-wide platform inventory, set up once - a board delivers fine with none',
|
|
173
|
+
},
|
|
169
174
|
}
|
|
170
175
|
const advanced = NAV_CONTRIBUTIONS.filter((i) => i.advanced).map((i) => i.id)
|
|
171
176
|
expect(advanced.sort()).toEqual(Object.keys(REASON).sort())
|
|
@@ -344,6 +349,7 @@ describe('nav grouping helpers', () => {
|
|
|
344
349
|
'keyboard-shortcuts',
|
|
345
350
|
'ui-mode',
|
|
346
351
|
'tutorial',
|
|
352
|
+
'foundational-services',
|
|
347
353
|
])
|
|
348
354
|
})
|
|
349
355
|
|
|
@@ -168,6 +168,7 @@ export const NAV_ACTIONS = [
|
|
|
168
168
|
'kaizen',
|
|
169
169
|
'infrastructure',
|
|
170
170
|
'fragmentLibrary',
|
|
171
|
+
'foundationalServices',
|
|
171
172
|
'mergeThresholds',
|
|
172
173
|
'workspaceSettings',
|
|
173
174
|
'modelConfiguration',
|
|
@@ -365,6 +366,31 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
365
366
|
keywordsKey: 'layout.commandBar.keywords.fragments',
|
|
366
367
|
},
|
|
367
368
|
},
|
|
369
|
+
{
|
|
370
|
+
// Registering the shared capabilities the ORGANISATION already runs, so a design consumes
|
|
371
|
+
// them instead of proposing a rebuild. Advanced: the everyday delivery loop (plan a task,
|
|
372
|
+
// run it, review and merge it) never touches this — it is org-wide platform configuration,
|
|
373
|
+
// set up once by whoever knows the estate, and a board can deliver its whole backlog with an
|
|
374
|
+
// empty catalog. Beside the fragment library in `workspaceContext`, because both answer
|
|
375
|
+
// "what standing context does an agent get?".
|
|
376
|
+
id: 'foundational-services',
|
|
377
|
+
labelKey: 'nav.foundationalServices',
|
|
378
|
+
icon: 'i-lucide-boxes',
|
|
379
|
+
surfaces: S('sidebar', 'command'),
|
|
380
|
+
advanced: true,
|
|
381
|
+
gate: (g) => g.canManageSettings,
|
|
382
|
+
action: 'foundationalServices',
|
|
383
|
+
testId: 'nav-foundational-services',
|
|
384
|
+
sidebar: { group: 'workspaceContext', order: 20 },
|
|
385
|
+
command: {
|
|
386
|
+
// Appended after the pre-existing workspace commands rather than interleaved beside the
|
|
387
|
+
// fragment library, so the palette order people already know is unchanged.
|
|
388
|
+
group: 'workspace',
|
|
389
|
+
order: 110,
|
|
390
|
+
labelKey: 'layout.commandBar.cmd.foundationalServices',
|
|
391
|
+
keywordsKey: 'layout.commandBar.keywords.foundationalServices',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
368
394
|
{
|
|
369
395
|
id: 'merge-thresholds',
|
|
370
396
|
labelKey: 'layout.commandBar.cmd.mergeThresholds',
|
package/app/pages/index.vue
CHANGED
|
@@ -73,6 +73,9 @@ const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPa
|
|
|
73
73
|
const FragmentLibraryPanel = defineAsyncComponent(
|
|
74
74
|
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
75
75
|
)
|
|
76
|
+
const FoundationalServicePanel = defineAsyncComponent(
|
|
77
|
+
() => import('~/components/foundational/FoundationalServicePanel.vue'),
|
|
78
|
+
)
|
|
76
79
|
// Startup advisory for invalid / outdated pipelines — only mounted while open (auto-opened
|
|
77
80
|
// at most once per session by the watcher below), so it stays out of the initial bundle.
|
|
78
81
|
const PipelineHealthModal = defineAsyncComponent(
|
|
@@ -487,6 +490,7 @@ watch(
|
|
|
487
490
|
<GitHubPanel v-if="ui.githubOpen" />
|
|
488
491
|
<SlackPanel v-if="ui.slackOpen" />
|
|
489
492
|
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
493
|
+
<FoundationalServicePanel v-if="ui.foundationalServicesOpen" />
|
|
490
494
|
<PipelineHealthModal v-if="ui.pipelineHealthOpen" />
|
|
491
495
|
<RiskPolicyHealthModal v-if="ui.riskPolicyHealthOpen" />
|
|
492
496
|
<ModelPresetHealthModal v-if="ui.modelPresetHealthOpen" />
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useFoundationalServicesStore } from '~/stores/foundationalServices'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
|
|
5
|
+
// The two store rules that the surface's correctness rests on, and that neither the backend
|
|
6
|
+
// tests nor a component test would catch:
|
|
7
|
+
//
|
|
8
|
+
// - opening the catalog must transfer NO contract document. The whole two-table split exists so
|
|
9
|
+
// a catalog read costs identity + operation names, and a store that eagerly hydrated bodies
|
|
10
|
+
// would quietly undo it on the one surface a human uses to check what an agent sees.
|
|
11
|
+
// - a suppress/restore must refresh the OPT-OUT list as well as the catalog. The two are
|
|
12
|
+
// complements — an entry leaves one exactly as it enters the other — so refreshing only the
|
|
13
|
+
// catalog leaves the way BACK stale, which is the failure the pair exists to prevent.
|
|
14
|
+
|
|
15
|
+
const SERVICE = {
|
|
16
|
+
id: 'file-storage',
|
|
17
|
+
ownerKind: 'account' as const,
|
|
18
|
+
name: 'File Storage',
|
|
19
|
+
summary: 'Stores uploads.',
|
|
20
|
+
description: '',
|
|
21
|
+
capabilities: [],
|
|
22
|
+
contracts: [
|
|
23
|
+
{
|
|
24
|
+
contractId: 'openapi',
|
|
25
|
+
format: 'openapi',
|
|
26
|
+
title: 'HTTP API',
|
|
27
|
+
size: 42,
|
|
28
|
+
path: null,
|
|
29
|
+
operations: ['GET /files'],
|
|
30
|
+
omittedOperations: 0,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
sourceId: null,
|
|
34
|
+
sourcePath: null,
|
|
35
|
+
pinnedCommit: null,
|
|
36
|
+
createdAt: 1,
|
|
37
|
+
updatedAt: 1,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function api(over: Record<string, unknown> = {}) {
|
|
41
|
+
return {
|
|
42
|
+
listFoundationalServices: vi.fn(() => Promise.resolve([])),
|
|
43
|
+
getResolvedFoundationalServices: vi.fn(() =>
|
|
44
|
+
Promise.resolve([{ ...SERVICE, tier: 'account' }]),
|
|
45
|
+
),
|
|
46
|
+
listFoundationalServiceSuppressions: vi.fn(() => Promise.resolve([])),
|
|
47
|
+
listFoundationalSources: vi.fn(() => Promise.resolve([])),
|
|
48
|
+
getFoundationalServiceContracts: vi.fn(() =>
|
|
49
|
+
Promise.resolve([{ ...SERVICE.contracts[0], body: 'openapi: 3.0.3' }]),
|
|
50
|
+
),
|
|
51
|
+
suppressFoundationalService: vi.fn(() => Promise.resolve(undefined)),
|
|
52
|
+
restoreFoundationalService: vi.fn(() => Promise.resolve(undefined)),
|
|
53
|
+
...over,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('foundational-services store', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('probes the catalog without fetching a single contract document', async () => {
|
|
63
|
+
const client = api()
|
|
64
|
+
vi.stubGlobal('useApi', () => client)
|
|
65
|
+
const store = useFoundationalServicesStore()
|
|
66
|
+
await store.probe()
|
|
67
|
+
|
|
68
|
+
expect(store.resolved).toHaveLength(1)
|
|
69
|
+
// The manifest rode the catalog read — the body did not.
|
|
70
|
+
expect(store.resolved[0]?.contracts[0]?.operations).toEqual(['GET /files'])
|
|
71
|
+
expect(client.getFoundationalServiceContracts).not.toHaveBeenCalled()
|
|
72
|
+
expect(store.contractBodies).toEqual({})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('fetches a document only on demand, then serves it from the session cache', async () => {
|
|
76
|
+
const client = api()
|
|
77
|
+
vi.stubGlobal('useApi', () => client)
|
|
78
|
+
const store = useFoundationalServicesStore()
|
|
79
|
+
await store.probe()
|
|
80
|
+
|
|
81
|
+
await store.contractsFor('file-storage')
|
|
82
|
+
await store.contractsFor('file-storage')
|
|
83
|
+
expect(client.getFoundationalServiceContracts).toHaveBeenCalledTimes(1)
|
|
84
|
+
expect(store.contractBodies['file-storage']?.[0]?.body).toBe('openapi: 3.0.3')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('resets the repo-source flag too when a re-probe finds the catalog gone', async () => {
|
|
88
|
+
// `sourcesAvailable` gates an affordance rather than content, so a probe that leaves it at a
|
|
89
|
+
// previous `true` would offer repo-source linking against an owner whose catalog is now
|
|
90
|
+
// unreachable. Every view the probe owns has to come back down together.
|
|
91
|
+
const client = api()
|
|
92
|
+
vi.stubGlobal('useApi', () => client)
|
|
93
|
+
const store = useFoundationalServicesStore()
|
|
94
|
+
await store.probe()
|
|
95
|
+
expect(store.sourcesAvailable).toBe(true)
|
|
96
|
+
|
|
97
|
+
client.listFoundationalServices.mockRejectedValueOnce(new Error('503'))
|
|
98
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
99
|
+
await store.probe()
|
|
100
|
+
|
|
101
|
+
expect(store.available).toBe(false)
|
|
102
|
+
expect(store.sourcesAvailable).toBe(false)
|
|
103
|
+
expect(store.sources).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('refreshes the opt-out list alongside the catalog on suppress and restore', async () => {
|
|
107
|
+
const client = api()
|
|
108
|
+
vi.stubGlobal('useApi', () => client)
|
|
109
|
+
const store = useFoundationalServicesStore()
|
|
110
|
+
await store.probe()
|
|
111
|
+
const afterProbe = client.listFoundationalServiceSuppressions.mock.calls.length
|
|
112
|
+
|
|
113
|
+
await store.suppress('file-storage')
|
|
114
|
+
await store.restore('file-storage')
|
|
115
|
+
|
|
116
|
+
// Once per write: a suppression that only refreshed the catalog would leave the restore
|
|
117
|
+
// control missing for the very service just hidden.
|
|
118
|
+
expect(client.listFoundationalServiceSuppressions.mock.calls.length).toBe(afterProbe + 2)
|
|
119
|
+
expect(client.getResolvedFoundationalServices.mock.calls.length).toBe(afterProbe + 2)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
ApiContractDocument,
|
|
5
|
+
CreateFoundationalServiceInput,
|
|
6
|
+
FoundationalService,
|
|
7
|
+
FoundationalServiceOwnerKind,
|
|
8
|
+
FoundationalServiceSource,
|
|
9
|
+
FoundationalServiceSuppression,
|
|
10
|
+
FoundationalServiceSyncResult,
|
|
11
|
+
LinkFoundationalServiceSourceInput,
|
|
12
|
+
ResolvedFoundationalService,
|
|
13
|
+
UpdateFoundationalServiceInput,
|
|
14
|
+
} from '~/types/domain'
|
|
15
|
+
import { useSingleFlightProbe } from '~/composables/useSingleFlightProbe'
|
|
16
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The foundational-services catalog for one owner — a board (`workspace`) or an `account`
|
|
20
|
+
* (backend/docs/adr/0031-foundational-services.md). Holds that owner's own (raw) tier of registered
|
|
21
|
+
* services, its linked repo sources, and — at the **workspace** tier only — the merged catalog an
|
|
22
|
+
* Architect actually sees (account ⊕ workspace, workspace winning).
|
|
23
|
+
*
|
|
24
|
+
* Three things about this store follow the feature's design rather than the fragment library's:
|
|
25
|
+
*
|
|
26
|
+
* - **Contract documents are never held here.** The catalog reads carry a contract MANIFEST
|
|
27
|
+
* (id, format, size, operation names) and no bodies, because that is the whole reason a design
|
|
28
|
+
* prompt scales with the number of an org's services rather than the size of its specs. A body
|
|
29
|
+
* is fetched on demand, per service, by {@link contractsFor}, and cached only for the session.
|
|
30
|
+
* - **`suppress` / `restore` are workspace-only**, and they are not "delete". Suppression opts a
|
|
31
|
+
* board out of an INHERITED account service and destroys nothing; deleting removes the board's
|
|
32
|
+
* own registration and its uploaded documents.
|
|
33
|
+
* - **`sourcesAvailable` is the finer gate**, exactly as in the skill library: the catalog works
|
|
34
|
+
* without the GitHub integration (contracts can be uploaded directly), while the repo-source
|
|
35
|
+
* routes 503 without it.
|
|
36
|
+
*/
|
|
37
|
+
function foundationalServicesSetup(
|
|
38
|
+
kind: FoundationalServiceOwnerKind,
|
|
39
|
+
resolveOwnerId: () => string | null,
|
|
40
|
+
) {
|
|
41
|
+
const api = useApi()
|
|
42
|
+
|
|
43
|
+
/** The merged/resolved catalog only exists at the workspace tier. */
|
|
44
|
+
const hasResolved = kind === 'workspace'
|
|
45
|
+
|
|
46
|
+
/** null = not probed yet; true/false = the catalog is on/off for this deployment. */
|
|
47
|
+
const available = ref<boolean | null>(null)
|
|
48
|
+
/** false when the GitHub integration is off: the catalog works, repo sources do not. */
|
|
49
|
+
const sourcesAvailable = ref(true)
|
|
50
|
+
/** This owner's own registered services (its tier, raw). */
|
|
51
|
+
const services = ref<FoundationalService[]>([])
|
|
52
|
+
/** The merged catalog an Architect sees (workspace tier only; empty otherwise). */
|
|
53
|
+
const resolved = ref<ResolvedFoundationalService[]>([])
|
|
54
|
+
/**
|
|
55
|
+
* What this board is opted OUT of (workspace tier only; empty otherwise). Its own read because
|
|
56
|
+
* a suppressed id is by construction absent from {@link resolved} — without it, suppression
|
|
57
|
+
* would be a one-way door.
|
|
58
|
+
*/
|
|
59
|
+
const suppressions = ref<FoundationalServiceSuppression[]>([])
|
|
60
|
+
/** Linked repo sources for this owner. */
|
|
61
|
+
const sources = ref<FoundationalServiceSource[]>([])
|
|
62
|
+
/** Per-source "changes available" flag from the last status check. */
|
|
63
|
+
const sourceChanges = ref<Record<string, boolean>>({})
|
|
64
|
+
/**
|
|
65
|
+
* Session cache of fetched contract DOCUMENTS, keyed by service id. Populated only by an
|
|
66
|
+
* explicit {@link contractsFor}, so opening the surface never transfers a document body — the
|
|
67
|
+
* property the whole two-table split exists to guarantee.
|
|
68
|
+
*/
|
|
69
|
+
const contractBodies = ref<Record<string, ApiContractDocument[]>>({})
|
|
70
|
+
|
|
71
|
+
/** How many entries of the merged catalog this board inherits rather than owns. */
|
|
72
|
+
const inheritedCount = computed(
|
|
73
|
+
() => resolved.value.filter((entry) => entry.tier === 'account').length,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
function requireOwnerId(): string {
|
|
77
|
+
const id = resolveOwnerId()
|
|
78
|
+
if (!id) throw new Error('No foundational-services owner')
|
|
79
|
+
return id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function requireWorkspaceId(): string {
|
|
83
|
+
if (!hasResolved) throw new Error('Foundational-service inheritance is workspace-scoped')
|
|
84
|
+
return requireOwnerId()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Probe the feature + load this owner's tier, sources and (ws) the merged catalog. */
|
|
88
|
+
async function runProbe() {
|
|
89
|
+
const id = resolveOwnerId()
|
|
90
|
+
if (!id) return
|
|
91
|
+
try {
|
|
92
|
+
const [tier, merged, opted] = await Promise.all([
|
|
93
|
+
api.listFoundationalServices(kind, id),
|
|
94
|
+
hasResolved
|
|
95
|
+
? api.getResolvedFoundationalServices(id)
|
|
96
|
+
: Promise.resolve([] as ResolvedFoundationalService[]),
|
|
97
|
+
hasResolved
|
|
98
|
+
? api.listFoundationalServiceSuppressions(id)
|
|
99
|
+
: Promise.resolve([] as FoundationalServiceSuppression[]),
|
|
100
|
+
])
|
|
101
|
+
services.value = tier
|
|
102
|
+
resolved.value = merged
|
|
103
|
+
suppressions.value = opted
|
|
104
|
+
available.value = true
|
|
105
|
+
} catch {
|
|
106
|
+
// Reset EVERY view, `sourcesAvailable` included. Leaving it at its previous value would
|
|
107
|
+
// let a re-probe of an owner whose catalog is now unreachable keep claiming the repo-source
|
|
108
|
+
// half is wired, which is the one flag here that gates an affordance rather than content.
|
|
109
|
+
available.value = false
|
|
110
|
+
services.value = []
|
|
111
|
+
resolved.value = []
|
|
112
|
+
suppressions.value = []
|
|
113
|
+
sources.value = []
|
|
114
|
+
sourceChanges.value = {}
|
|
115
|
+
sourcesAvailable.value = false
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
// Repo sources need the GitHub integration; a 503 here hides only the linking UI — the
|
|
119
|
+
// catalog read above already succeeded, so the feature itself is on.
|
|
120
|
+
try {
|
|
121
|
+
sources.value = await api.listFoundationalSources(kind, id)
|
|
122
|
+
sourcesAvailable.value = true
|
|
123
|
+
} catch {
|
|
124
|
+
sources.value = []
|
|
125
|
+
sourcesAvailable.value = false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Single-flight the probe keyed on the owner id, so a panel-open fan-out loads once per owner.
|
|
129
|
+
const { probe, ensureProbed } = useSingleFlightProbe(runProbe, () => resolveOwnerId())
|
|
130
|
+
|
|
131
|
+
async function reloadTier() {
|
|
132
|
+
services.value = await api.listFoundationalServices(kind, requireOwnerId())
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function refreshResolved() {
|
|
136
|
+
if (!hasResolved) return
|
|
137
|
+
const id = requireOwnerId()
|
|
138
|
+
// Both in one pass: a write that changes the merge routinely changes the opt-out list too
|
|
139
|
+
// (suppressing removes an entry from one and adds it to the other), and refreshing only the
|
|
140
|
+
// catalog would leave the way BACK stale — the exact state the pair exists to avoid.
|
|
141
|
+
const [merged, opted] = await Promise.all([
|
|
142
|
+
api.getResolvedFoundationalServices(id),
|
|
143
|
+
api.listFoundationalServiceSuppressions(id),
|
|
144
|
+
])
|
|
145
|
+
resolved.value = merged
|
|
146
|
+
suppressions.value = opted
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every write invalidates both views: a tier edit changes what the merge resolves to. */
|
|
150
|
+
async function reload() {
|
|
151
|
+
await Promise.all([reloadTier(), refreshResolved()])
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function create(input: CreateFoundationalServiceInput) {
|
|
155
|
+
await api.createFoundationalService(kind, requireOwnerId(), input)
|
|
156
|
+
await reload()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function update(serviceId: string, patch: UpdateFoundationalServiceInput) {
|
|
160
|
+
await api.updateFoundationalService(kind, requireOwnerId(), serviceId, patch)
|
|
161
|
+
// A contract replacement changes the stored bodies, so drop the cached copy rather than
|
|
162
|
+
// letting a stale document be shown as what a consumer would receive.
|
|
163
|
+
delete contractBodies.value[serviceId]
|
|
164
|
+
await reload()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Remove this tier's OWN registration (and its uploaded documents). */
|
|
168
|
+
async function remove(serviceId: string) {
|
|
169
|
+
await api.deleteFoundationalService(kind, requireOwnerId(), serviceId)
|
|
170
|
+
delete contractBodies.value[serviceId]
|
|
171
|
+
await reload()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Opt this board out of an inherited ACCOUNT service. Destroys nothing; reversible. */
|
|
175
|
+
async function suppress(serviceId: string) {
|
|
176
|
+
await api.suppressFoundationalService(requireWorkspaceId(), serviceId)
|
|
177
|
+
await reload()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Lift a suppression, so the board inherits the account service again. */
|
|
181
|
+
async function restore(serviceId: string) {
|
|
182
|
+
await api.restoreFoundationalService(requireWorkspaceId(), serviceId)
|
|
183
|
+
await reload()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Fetch (and cache for the session) one service's full contract documents — the same lazy read
|
|
188
|
+
* a consumer dispatch makes, so what a human inspects here is what an agent would be handed.
|
|
189
|
+
*/
|
|
190
|
+
async function contractsFor(serviceId: string): Promise<ApiContractDocument[]> {
|
|
191
|
+
const cached = contractBodies.value[serviceId]
|
|
192
|
+
if (cached) return cached
|
|
193
|
+
const documents = await api.getFoundationalServiceContracts(requireWorkspaceId(), serviceId)
|
|
194
|
+
contractBodies.value = { ...contractBodies.value, [serviceId]: documents }
|
|
195
|
+
return documents
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function reloadSources() {
|
|
199
|
+
sources.value = await api.listFoundationalSources(kind, requireOwnerId())
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function linkSource(input: LinkFoundationalServiceSourceInput) {
|
|
203
|
+
const source = await api.linkFoundationalSource(kind, requireOwnerId(), input)
|
|
204
|
+
sources.value = [source, ...sources.value]
|
|
205
|
+
// Sync immediately so the linked repo's services land in the catalog rather than waiting for
|
|
206
|
+
// the autorefresh sweep — a freshly linked source that shows nothing reads as a broken link.
|
|
207
|
+
await syncSource(source.id)
|
|
208
|
+
return source
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function unlinkSource(sourceId: string) {
|
|
212
|
+
await api.unlinkFoundationalSource(kind, requireOwnerId(), sourceId)
|
|
213
|
+
sources.value = sources.value.filter((s) => s.id !== sourceId)
|
|
214
|
+
delete sourceChanges.value[sourceId]
|
|
215
|
+
await reload()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Resync a source's definitions into the catalog, then refresh both views. */
|
|
219
|
+
async function syncSource(sourceId: string): Promise<FoundationalServiceSyncResult> {
|
|
220
|
+
const result = await api.syncFoundationalSource(kind, requireOwnerId(), sourceId)
|
|
221
|
+
delete sourceChanges.value[sourceId]
|
|
222
|
+
await Promise.all([reloadSources(), reload()])
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The cheap commit-version "check for changes" for a source; caches the flag. */
|
|
227
|
+
async function checkSource(sourceId: string) {
|
|
228
|
+
const status = await api.foundationalSourceStatus(kind, requireOwnerId(), sourceId)
|
|
229
|
+
sourceChanges.value = { ...sourceChanges.value, [sourceId]: status.changed }
|
|
230
|
+
return status
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
kind,
|
|
235
|
+
hasResolved,
|
|
236
|
+
available,
|
|
237
|
+
sourcesAvailable,
|
|
238
|
+
services,
|
|
239
|
+
resolved,
|
|
240
|
+
suppressions,
|
|
241
|
+
sources,
|
|
242
|
+
sourceChanges,
|
|
243
|
+
contractBodies,
|
|
244
|
+
inheritedCount,
|
|
245
|
+
probe,
|
|
246
|
+
ensureProbed,
|
|
247
|
+
create,
|
|
248
|
+
update,
|
|
249
|
+
remove,
|
|
250
|
+
suppress,
|
|
251
|
+
restore,
|
|
252
|
+
contractsFor,
|
|
253
|
+
linkSource,
|
|
254
|
+
unlinkSource,
|
|
255
|
+
syncSource,
|
|
256
|
+
checkSource,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The workspace-tier catalog for the **active** board — a singleton that resolves the owner
|
|
262
|
+
* lazily, so it follows board switches and is shared by the navbar and the board modal.
|
|
263
|
+
*/
|
|
264
|
+
export const useFoundationalServicesStore = defineStore('foundationalServices', () =>
|
|
265
|
+
foundationalServicesSetup('workspace', () => useWorkspaceStore().workspaceId),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An owner-keyed catalog store, used for the **account** tier (and reusable for any explicit
|
|
270
|
+
* owner). Keyed by `(kind, ownerId)` so each account gets isolated state.
|
|
271
|
+
*/
|
|
272
|
+
export function useFoundationalServices(kind: FoundationalServiceOwnerKind, ownerId: string) {
|
|
273
|
+
return defineStore(`foundationalServices:${kind}:${ownerId}`, () =>
|
|
274
|
+
foundationalServicesSetup(kind, () => ownerId),
|
|
275
|
+
)()
|
|
276
|
+
}
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -344,6 +344,9 @@ function createOverlayModals() {
|
|
|
344
344
|
// Prompt-fragment library panel (manage the board's best-practice catalog +
|
|
345
345
|
// linked guideline repos; ADR 0006).
|
|
346
346
|
const fragmentLibraryOpen = ref(false)
|
|
347
|
+
// Foundational-services panel (register the shared capabilities the org already runs, and see
|
|
348
|
+
// the merged catalog an Architect designs against; backend/docs/adr/0031-foundational-services.md).
|
|
349
|
+
const foundationalServicesOpen = ref(false)
|
|
347
350
|
// Command bar (⌘K) — searchable launcher for every navbar action.
|
|
348
351
|
const commandBarOpen = ref(false)
|
|
349
352
|
// Keyboard-shortcuts cheatsheet (?) — a modal listing every global shortcut.
|
|
@@ -373,6 +376,12 @@ function createOverlayModals() {
|
|
|
373
376
|
function closeFragmentLibrary() {
|
|
374
377
|
fragmentLibraryOpen.value = false
|
|
375
378
|
}
|
|
379
|
+
function openFoundationalServices() {
|
|
380
|
+
foundationalServicesOpen.value = true
|
|
381
|
+
}
|
|
382
|
+
function closeFoundationalServices() {
|
|
383
|
+
foundationalServicesOpen.value = false
|
|
384
|
+
}
|
|
376
385
|
function openCommandBar() {
|
|
377
386
|
commandBarOpen.value = true
|
|
378
387
|
}
|
|
@@ -411,6 +420,7 @@ function createOverlayModals() {
|
|
|
411
420
|
bootstrapOpen,
|
|
412
421
|
addServiceOpen,
|
|
413
422
|
fragmentLibraryOpen,
|
|
423
|
+
foundationalServicesOpen,
|
|
414
424
|
commandBarOpen,
|
|
415
425
|
shortcutsHelpOpen,
|
|
416
426
|
mobileNavOpen,
|
|
@@ -421,6 +431,8 @@ function createOverlayModals() {
|
|
|
421
431
|
closeAddService,
|
|
422
432
|
openFragmentLibrary,
|
|
423
433
|
closeFragmentLibrary,
|
|
434
|
+
openFoundationalServices,
|
|
435
|
+
closeFoundationalServices,
|
|
424
436
|
openCommandBar,
|
|
425
437
|
closeCommandBar,
|
|
426
438
|
toggleCommandBar,
|
package/app/types/domain.ts
CHANGED
|
@@ -177,6 +177,7 @@ export type * from './execution'
|
|
|
177
177
|
export type * from './models'
|
|
178
178
|
export type * from './fragments'
|
|
179
179
|
export type * from './skills'
|
|
180
|
+
export type * from './foundationalServices'
|
|
180
181
|
export type * from './documents'
|
|
181
182
|
export type * from './tasks'
|
|
182
183
|
export type * from './bugHunt'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Foundational services (backend/docs/adr/0031-foundational-services.md). Mirrors the
|
|
3
|
+
// `@cat-factory/contracts` schemas: the tiered catalog of shared capabilities an
|
|
4
|
+
// organisation already runs (file storage, notifications, audit …), each with its
|
|
5
|
+
// API contracts, plus the repo sources that feed it.
|
|
6
|
+
//
|
|
7
|
+
// Note `ApiContractSummary` (no body) and `ApiContractDocument` (with body) are two
|
|
8
|
+
// separate types on purpose — the catalog read never carries a document, and the
|
|
9
|
+
// management surface reads one only when a human opens it.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
//
|
|
12
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth).
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
ApiContractDocument,
|
|
16
|
+
ApiContractFormat,
|
|
17
|
+
ApiContractSummary,
|
|
18
|
+
CreateFoundationalServiceInput,
|
|
19
|
+
FoundationalService,
|
|
20
|
+
FoundationalServiceOwnerKind,
|
|
21
|
+
FoundationalServiceSelection,
|
|
22
|
+
FoundationalServiceSource,
|
|
23
|
+
FoundationalServiceSourceMode,
|
|
24
|
+
FoundationalServiceSourceStatus,
|
|
25
|
+
FoundationalServiceSuppression,
|
|
26
|
+
FoundationalServiceSyncResult,
|
|
27
|
+
FoundationalServiceTier,
|
|
28
|
+
LinkFoundationalServiceSourceInput,
|
|
29
|
+
ResolvedFoundationalService,
|
|
30
|
+
UpdateFoundationalServiceInput,
|
|
31
|
+
UploadApiContract,
|
|
32
|
+
} from '@cat-factory/contracts'
|