@cat-factory/app 0.115.2 → 0.116.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/fragments/FragmentLibraryManager.vue +102 -46
- package/app/components/panels/inspector/ServiceTestConfig.vue +153 -1
- package/app/components/slack/SlackPanel.vue +40 -10
- package/app/composables/api/environments.ts +16 -1
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useWorkspaceStream.ts +6 -0
- package/app/stores/environmentTest.spec.ts +103 -0
- package/app/stores/environmentTest.ts +114 -0
- package/app/stores/workspace.spec.ts +1 -0
- package/app/stores/workspace.ts +2 -0
- package/app/types/domain.ts +3 -0
- package/app/utils/slackMemberMapping.spec.ts +94 -0
- package/app/utils/slackMemberMapping.ts +46 -0
- package/i18n/locales/de.json +30 -2
- package/i18n/locales/en.json +30 -2
- package/i18n/locales/es.json +30 -2
- package/i18n/locales/fr.json +30 -2
- package/i18n/locales/he.json +30 -2
- package/i18n/locales/it.json +30 -2
- package/i18n/locales/ja.json +30 -2
- package/i18n/locales/pl.json +30 -2
- package/i18n/locales/tr.json +30 -2
- package/i18n/locales/uk.json +30 -2
- package/package.json +6 -6
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import type { EnvironmentTestRun } from '~/types/domain'
|
|
3
|
+
import { useEnvironmentTestStore } from '~/stores/environmentTest'
|
|
4
|
+
|
|
5
|
+
// The store resolves `useApi()` at setup; override the inert global stub from
|
|
6
|
+
// `test/setup.ts` with a per-suite mock so the hydrate reconcile point-read is observable.
|
|
7
|
+
const apiMock = { getEnvironmentTest: vi.fn() }
|
|
8
|
+
vi.stubGlobal('useApi', () => apiMock)
|
|
9
|
+
|
|
10
|
+
/** Minimal EnvironmentTestRun factory — only the fields the store's reconcile logic touches. */
|
|
11
|
+
function run(id: string, over: Partial<EnvironmentTestRun> = {}): EnvironmentTestRun {
|
|
12
|
+
return {
|
|
13
|
+
id,
|
|
14
|
+
workspaceId: 'ws_test',
|
|
15
|
+
blockId: `blk_${id}`,
|
|
16
|
+
status: 'running',
|
|
17
|
+
stage: 'provisioning',
|
|
18
|
+
branch: null,
|
|
19
|
+
envUrl: null,
|
|
20
|
+
error: null,
|
|
21
|
+
failedStage: null,
|
|
22
|
+
createdAt: 1,
|
|
23
|
+
updatedAt: 1,
|
|
24
|
+
...over,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('environmentTest store — monotonic run reconcile', () => {
|
|
29
|
+
let store: ReturnType<typeof useEnvironmentTestStore>
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
apiMock.getEnvironmentTest = vi.fn(async () => {
|
|
32
|
+
throw new Error('not stubbed')
|
|
33
|
+
})
|
|
34
|
+
store = useEnvironmentTestStore()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('hydrate does NOT regress a run a newer live event already advanced', () => {
|
|
38
|
+
// A live `envTest: failed` event landed first (newer updatedAt).
|
|
39
|
+
store.upsert(run('r1', { status: 'failed', failedStage: 'provisioning', updatedAt: 5 }))
|
|
40
|
+
// A lagging `workspace.refresh()` then hydrates a STALE snapshot that still saw the run
|
|
41
|
+
// as `running` (older updatedAt) — it must NOT clobber the terminal state (terminal runs
|
|
42
|
+
// emit nothing further, so the inspector would be stuck on "testing" forever).
|
|
43
|
+
store.hydrate([run('r1', { status: 'running', updatedAt: 2 })], 'ws_test')
|
|
44
|
+
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('hydrate does NOT drop a live-added run the stale snapshot never saw', () => {
|
|
48
|
+
// Terminal runs are omitted from the snapshot BY DESIGN, so a just-finished run the
|
|
49
|
+
// inspector still shows must survive a full refresh.
|
|
50
|
+
store.upsert(run('r1', { status: 'succeeded', stage: 'done', updatedAt: 5 }))
|
|
51
|
+
store.hydrate([], 'ws_test')
|
|
52
|
+
expect(store.runForBlock('blk_r1')!.status).toBe('succeeded')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('hydrate point-reads a preserved RUNNING run the snapshot omitted (finished offline)', async () => {
|
|
56
|
+
// The run was still `running` when the socket dropped; it finished while disconnected, so
|
|
57
|
+
// the reconnect snapshot no longer carries it and no event replays — the hydrate must
|
|
58
|
+
// re-read it to pick up the outcome instead of stranding a stale "testing" state.
|
|
59
|
+
store.upsert(run('r1', { status: 'running', updatedAt: 5 }))
|
|
60
|
+
apiMock.getEnvironmentTest = vi.fn(async () =>
|
|
61
|
+
run('r1', { status: 'succeeded', stage: 'done', updatedAt: 9 }),
|
|
62
|
+
)
|
|
63
|
+
store.hydrate([], 'ws_test')
|
|
64
|
+
expect(apiMock.getEnvironmentTest).toHaveBeenCalledWith('ws_test', 'r1')
|
|
65
|
+
await vi.waitFor(() => expect(store.runForBlock('blk_r1')!.status).toBe('succeeded'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('a STALE point-read cannot regress a run a live event advanced meanwhile', async () => {
|
|
69
|
+
store.upsert(run('r1', { status: 'running', updatedAt: 5 }))
|
|
70
|
+
// The reconcile read resolves with an OLDER view of the run than the live event that
|
|
71
|
+
// lands while it is in flight — the monotonic upsert must keep the newer state.
|
|
72
|
+
apiMock.getEnvironmentTest = vi.fn(async () => run('r1', { status: 'running', updatedAt: 4 }))
|
|
73
|
+
store.hydrate([], 'ws_test')
|
|
74
|
+
store.upsert(run('r1', { status: 'failed', failedStage: 'tearing_down', updatedAt: 8 }))
|
|
75
|
+
await vi.waitFor(() => expect(apiMock.getEnvironmentTest).toHaveBeenCalled())
|
|
76
|
+
await Promise.resolve()
|
|
77
|
+
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('hydrate DROPS a cached run from a different workspace (board switch starts clean)', () => {
|
|
81
|
+
store.upsert(run('r1', { status: 'failed', updatedAt: 5, workspaceId: 'ws_other' }))
|
|
82
|
+
store.hydrate([run('r2', { workspaceId: 'ws_test' })], 'ws_test')
|
|
83
|
+
expect(store.runs.map((r) => r.id)).toEqual(['r2'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hydrate DOES apply a genuinely newer snapshot', () => {
|
|
87
|
+
store.upsert(run('r1', { status: 'running', updatedAt: 2 }))
|
|
88
|
+
store.hydrate(
|
|
89
|
+
[run('r1', { status: 'running', stage: 'tearing_down', updatedAt: 9 })],
|
|
90
|
+
'ws_test',
|
|
91
|
+
)
|
|
92
|
+
expect(store.runForBlock('blk_r1')!.stage).toBe('tearing_down')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('upsert ignores an older/out-of-order write but applies newer/equal', () => {
|
|
96
|
+
store.upsert(run('r1', { status: 'failed', updatedAt: 5 }))
|
|
97
|
+
// e.g. a `start()` response resolving AFTER the fast-failing run's terminal event landed.
|
|
98
|
+
store.upsert(run('r1', { status: 'running', stage: 'creating_branch', updatedAt: 3 }))
|
|
99
|
+
expect(store.runForBlock('blk_r1')!.status).toBe('failed')
|
|
100
|
+
store.upsert(run('r1', { status: 'succeeded', stage: 'done', updatedAt: 5 }))
|
|
101
|
+
expect(store.runForBlock('blk_r1')!.status).toBe('succeeded')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import type { EnvironmentTestRun } from '~/types/domain'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ephemeral-environment self-test runs. A developer starts one from a service frame's inspector
|
|
8
|
+
* (`POST …/blocks/:id/environment-test`); the backend drives the create-branch → provision →
|
|
9
|
+
* tear-down → delete-branch cycle durably and pushes live `envTest` stage events, which
|
|
10
|
+
* `useWorkspaceStream` folds in via {@link upsert}. In-flight runs also arrive in the workspace
|
|
11
|
+
* snapshot ({@link hydrate}) so the inspector re-attaches to a running test after a reconnect.
|
|
12
|
+
*
|
|
13
|
+
* Runs are keyed by their FRAME block id for the inspector's per-service lookup ({@link runForBlock}
|
|
14
|
+
* returns the newest run for a block). Terminal runs are kept in memory for the session so the
|
|
15
|
+
* inspector can show the last outcome; the snapshot only carries running ones.
|
|
16
|
+
*/
|
|
17
|
+
export const useEnvironmentTestStore = defineStore('environmentTest', () => {
|
|
18
|
+
const api = useApi()
|
|
19
|
+
|
|
20
|
+
/** All known runs (running + this session's terminal ones), newest first. */
|
|
21
|
+
const runs = ref<EnvironmentTestRun[]>([])
|
|
22
|
+
|
|
23
|
+
function sortByCreated(list: EnvironmentTestRun[]): EnvironmentTestRun[] {
|
|
24
|
+
return [...list].sort((a, b) => b.createdAt - a.createdAt)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reconcile the cached runs with a server snapshot for `workspaceId`. A snapshot is
|
|
29
|
+
* authoritative EXCEPT where a live `envTest` event has already advanced (or ADDED) a run
|
|
30
|
+
* past what this (possibly stale) read observed — a `board`-event refresh or the on-connect
|
|
31
|
+
* resync can resolve AFTER a newer event already landed. Same two clobber hazards as
|
|
32
|
+
* `agentRuns.hydrate`, both handled here:
|
|
33
|
+
* - REGRESS: a run present in BOTH the snapshot and the cache — keep the newer-by-`updatedAt`
|
|
34
|
+
* version, so a lagging refresh can't revert a `failed`/`succeeded` run to `running`
|
|
35
|
+
* (terminal runs emit nothing further, so the inspector would be stuck on "testing").
|
|
36
|
+
* - DROP: a run a live event just ADDED that the (older) snapshot never saw — replacing from
|
|
37
|
+
* the snapshot alone would silently drop it (and terminal runs are omitted from the
|
|
38
|
+
* snapshot by design, so a finished run the inspector still shows would vanish).
|
|
39
|
+
* Preserve such cached runs, scoped to `workspaceId` so a board SWITCH still starts clean.
|
|
40
|
+
*
|
|
41
|
+
* A preserved RUNNING run absent from the snapshot may also have reached terminal while the
|
|
42
|
+
* socket was down (no event replays, and the snapshot omits terminal runs) — point-read it
|
|
43
|
+
* best-effort to pick up the outcome; {@link upsert}'s monotonic guard makes the read safe
|
|
44
|
+
* against racing live events.
|
|
45
|
+
*/
|
|
46
|
+
function hydrate(snapshotRuns: EnvironmentTestRun[], workspaceId: string) {
|
|
47
|
+
const incomingIds = new Set(snapshotRuns.map((r) => r.id))
|
|
48
|
+
const held = new Map(runs.value.map((r) => [r.id, r]))
|
|
49
|
+
const reconciled = snapshotRuns.map((incoming) => {
|
|
50
|
+
const current = held.get(incoming.id)
|
|
51
|
+
return current && current.updatedAt > incoming.updatedAt ? current : incoming
|
|
52
|
+
})
|
|
53
|
+
const preserved = [...held.values()].filter(
|
|
54
|
+
(r) => !incomingIds.has(r.id) && r.workspaceId === workspaceId,
|
|
55
|
+
)
|
|
56
|
+
runs.value = sortByCreated([...reconciled, ...preserved])
|
|
57
|
+
// A still-`running` preserved run wasn't in the snapshot, so either the snapshot is stale
|
|
58
|
+
// (the run is genuinely newer) or the run FINISHED while we were disconnected — resolve
|
|
59
|
+
// which by re-reading it (non-blocking; failures leave the cached state as-is).
|
|
60
|
+
for (const r of preserved) {
|
|
61
|
+
if (r.status === 'running') void reconcileRun(workspaceId, r.id)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Best-effort point-read of one run, folded in through the monotonic {@link upsert}. */
|
|
66
|
+
async function reconcileRun(workspaceId: string, id: string) {
|
|
67
|
+
try {
|
|
68
|
+
upsert(await api.getEnvironmentTest(workspaceId, id))
|
|
69
|
+
} catch {
|
|
70
|
+
// Best-effort: a transient fetch failure just leaves the cached state; the next
|
|
71
|
+
// snapshot/event reconciles it.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fold a live-pushed (or freshly-started/stopped) run into the cache. Monotonic by
|
|
77
|
+
* `updatedAt`: never let a stale/out-of-order write regress a run a newer one already
|
|
78
|
+
* advanced — e.g. a `start()` response resolving AFTER a fast-failing run's terminal
|
|
79
|
+
* event already landed (same guard as {@link hydrate}).
|
|
80
|
+
*/
|
|
81
|
+
function upsert(run: EnvironmentTestRun) {
|
|
82
|
+
const i = runs.value.findIndex((r) => r.id === run.id)
|
|
83
|
+
if (i >= 0) {
|
|
84
|
+
if (run.updatedAt >= runs.value[i]!.updatedAt) runs.value[i] = run
|
|
85
|
+
} else runs.value.unshift(run)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function runById(id: string): EnvironmentTestRun | undefined {
|
|
89
|
+
return runs.value.find((r) => r.id === id)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The newest run for a service frame — the inspector's per-service attach point. */
|
|
93
|
+
function runForBlock(blockId: string): EnvironmentTestRun | undefined {
|
|
94
|
+
return runs.value.find((r) => r.blockId === blockId)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Start a self-test against a service frame; the returned run is tracked immediately. */
|
|
98
|
+
async function start(blockId: string): Promise<EnvironmentTestRun> {
|
|
99
|
+
const ws = useWorkspaceStore()
|
|
100
|
+
const run = await api.startEnvironmentTest(ws.requireId(), blockId)
|
|
101
|
+
upsert(run)
|
|
102
|
+
return run
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Stop a running self-test (best-effort cleanup, then failed). */
|
|
106
|
+
async function stop(id: string): Promise<EnvironmentTestRun> {
|
|
107
|
+
const ws = useWorkspaceStore()
|
|
108
|
+
const run = await api.stopEnvironmentTest(ws.requireId(), id)
|
|
109
|
+
upsert(run)
|
|
110
|
+
return run
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { runs, hydrate, upsert, runById, runForBlock, start, stop }
|
|
114
|
+
})
|
package/app/stores/workspace.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { useBoardStore } from '~/stores/board'
|
|
|
12
12
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
13
13
|
import { useExecutionStore } from '~/stores/execution'
|
|
14
14
|
import { useAgentRunsStore } from '~/stores/agentRuns'
|
|
15
|
+
import { useEnvironmentTestStore } from '~/stores/environmentTest'
|
|
15
16
|
import { useNotificationsStore } from '~/stores/notifications'
|
|
16
17
|
import { useRiskPoliciesStore } from '~/stores/riskPolicies'
|
|
17
18
|
import { useSharedStacksStore } from '~/stores/sharedStacks'
|
|
@@ -120,6 +121,7 @@ export const useWorkspaceStore = defineStore(
|
|
|
120
121
|
useExecutionStore().hydrate(snapshot.executions, snapshot.workspace.id)
|
|
121
122
|
useAgentRunsStore().hydrate(snapshot.bootstrapJobs ?? [], snapshot.workspace.id)
|
|
122
123
|
useAgentRunsStore().hydrateEnvConfigRepair(snapshot.envConfigRepairJobs ?? [])
|
|
124
|
+
useEnvironmentTestStore().hydrate(snapshot.environmentTestRuns ?? [], snapshot.workspace.id)
|
|
123
125
|
useNotificationsStore().hydrate(snapshot.notifications ?? [])
|
|
124
126
|
useRiskPoliciesStore().hydrate(
|
|
125
127
|
snapshot.riskPolicies ?? [],
|
package/app/types/domain.ts
CHANGED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { SlackMemberMappingEntry } from '~/types/slack'
|
|
3
|
+
import {
|
|
4
|
+
type MemberRow,
|
|
5
|
+
emptyMemberRow,
|
|
6
|
+
hasHalfFilledRow,
|
|
7
|
+
toMemberEntries,
|
|
8
|
+
toMemberRow,
|
|
9
|
+
} from './slackMemberMapping'
|
|
10
|
+
|
|
11
|
+
const row = (partial: Partial<MemberRow> & { uid: string }): MemberRow => ({
|
|
12
|
+
userId: '',
|
|
13
|
+
slackUserId: '',
|
|
14
|
+
role: 'engineering',
|
|
15
|
+
...partial,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('hasHalfFilledRow', () => {
|
|
19
|
+
it('is false for fully-filled rows', () => {
|
|
20
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('is false for fully-empty rows (unused slots)', () => {
|
|
24
|
+
expect(hasHalfFilledRow([row({ uid: 'a' }), row({ uid: 'b' })])).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('is true when only the user id is filled', () => {
|
|
28
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: 'usr_1' })])).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('is true when only the Slack id is filled', () => {
|
|
32
|
+
expect(hasHalfFilledRow([row({ uid: 'a', slackUserId: 'U1' })])).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats whitespace-only ids as blank', () => {
|
|
36
|
+
expect(hasHalfFilledRow([row({ uid: 'a', userId: ' ', slackUserId: 'U1' })])).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('flags a half-filled row among valid ones', () => {
|
|
40
|
+
expect(
|
|
41
|
+
hasHalfFilledRow([
|
|
42
|
+
row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' }),
|
|
43
|
+
row({ uid: 'b', userId: 'usr_2' }),
|
|
44
|
+
]),
|
|
45
|
+
).toBe(true)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('toMemberEntries', () => {
|
|
50
|
+
it('keeps fully-filled rows, drops empty slots, and strips uid', () => {
|
|
51
|
+
const rows: MemberRow[] = [
|
|
52
|
+
row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1', role: 'product' }),
|
|
53
|
+
row({ uid: 'b' }), // empty slot — dropped
|
|
54
|
+
row({ uid: 'c', userId: 'usr_2', slackUserId: 'U2' }),
|
|
55
|
+
]
|
|
56
|
+
expect(toMemberEntries(rows)).toEqual([
|
|
57
|
+
{ userId: 'usr_1', slackUserId: 'U1', role: 'product' },
|
|
58
|
+
{ userId: 'usr_2', slackUserId: 'U2', role: 'engineering' },
|
|
59
|
+
])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not leak the client-only uid onto the wire payload', () => {
|
|
63
|
+
const [entry] = toMemberEntries([row({ uid: 'a', userId: 'usr_1', slackUserId: 'U1' })])
|
|
64
|
+
expect(entry).not.toHaveProperty('uid')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('toMemberRow', () => {
|
|
69
|
+
it('stamps the uid and defaults a missing role', () => {
|
|
70
|
+
const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1' }
|
|
71
|
+
expect(toMemberRow(entry, 'm1')).toEqual({
|
|
72
|
+
userId: 'usr_1',
|
|
73
|
+
slackUserId: 'U1',
|
|
74
|
+
role: 'engineering',
|
|
75
|
+
uid: 'm1',
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('preserves an explicit role', () => {
|
|
80
|
+
const entry: SlackMemberMappingEntry = { userId: 'usr_1', slackUserId: 'U1', role: 'product' }
|
|
81
|
+
expect(toMemberRow(entry, 'm2').role).toBe('product')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('emptyMemberRow', () => {
|
|
86
|
+
it('builds a blank engineering row with the given uid', () => {
|
|
87
|
+
expect(emptyMemberRow('m9')).toEqual({
|
|
88
|
+
uid: 'm9',
|
|
89
|
+
userId: '',
|
|
90
|
+
slackUserId: '',
|
|
91
|
+
role: 'engineering',
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SlackMemberMappingEntry } from '~/types/slack'
|
|
2
|
+
|
|
3
|
+
// Pure helpers for the Slack member-mapping editor (SlackPanel.vue). Extracted so
|
|
4
|
+
// the save-time integrity rules (UX-23) can be unit-tested without mounting the panel.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An editable member-map row: the wire entry plus a client-only stable `uid` so a
|
|
8
|
+
* mid-list delete keys the `v-model` by identity, not the array index (index keys
|
|
9
|
+
* silently rebound a neighbour's inputs — UX-23).
|
|
10
|
+
*/
|
|
11
|
+
export type MemberRow = SlackMemberMappingEntry & { uid: string }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* True when any row has exactly one of the two ids filled. A half-entered mapping
|
|
15
|
+
* used to be silently dropped on save (UX-23); the panel blocks the save instead so
|
|
16
|
+
* the user doesn't lose it. A fully-empty row is an unused slot, not half-filled.
|
|
17
|
+
*/
|
|
18
|
+
export function hasHalfFilledRow(
|
|
19
|
+
rows: readonly Pick<MemberRow, 'userId' | 'slackUserId'>[],
|
|
20
|
+
): boolean {
|
|
21
|
+
return rows.some((e) => Boolean(e.userId.trim()) !== Boolean(e.slackUserId.trim()))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The rows to persist: fully-filled only (empty slots dropped), with the client-only
|
|
26
|
+
* `uid` stripped from the wire payload.
|
|
27
|
+
*/
|
|
28
|
+
export function toMemberEntries(rows: readonly MemberRow[]): SlackMemberMappingEntry[] {
|
|
29
|
+
return rows
|
|
30
|
+
.filter((e) => e.userId.trim() && e.slackUserId.trim())
|
|
31
|
+
.map(({ uid: _uid, ...entry }) => entry)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrap a stored wire entry as an editable row: stamp a stable `uid` and default the
|
|
36
|
+
* `role` (absent on older maps) so the initial load and the post-save reload produce
|
|
37
|
+
* identical rows rather than drifting on the default.
|
|
38
|
+
*/
|
|
39
|
+
export function toMemberRow(entry: SlackMemberMappingEntry, uid: string): MemberRow {
|
|
40
|
+
return { role: 'engineering', ...entry, uid }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A fresh, empty editable row stamped with the given stable `uid`. */
|
|
44
|
+
export function emptyMemberRow(uid: string): MemberRow {
|
|
45
|
+
return { uid, userId: '', slackUserId: '', role: 'engineering' }
|
|
46
|
+
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -1093,6 +1093,23 @@
|
|
|
1093
1093
|
"title": "Einrichtung der Compose-Umgebung",
|
|
1094
1094
|
"hint": "Konfiguriere das Rezept, die Preflights und den Handler, damit der Deployer diesen Stack bereitstellt.",
|
|
1095
1095
|
"open": "Assistent öffnen"
|
|
1096
|
+
},
|
|
1097
|
+
"envTest": {
|
|
1098
|
+
"title": "Umgebungserstellung testen",
|
|
1099
|
+
"hint": "Führt den gesamten Lebenszyklus gegen einen Wegwerf-Branch aus: Branch erstellen, bereitstellen, abbauen, Branch löschen.",
|
|
1100
|
+
"start": "Umgebungserstellung testen",
|
|
1101
|
+
"stop": "Stopp",
|
|
1102
|
+
"infraless": "Konfiguriere oben einen Bereitstellungstyp, um die Umgebungserstellung zu testen.",
|
|
1103
|
+
"running": "Wird getestet: {stage}",
|
|
1104
|
+
"succeeded": "Test bestanden: Die Umgebung wurde erstellt und abgebaut, und der Branch wurde gelöscht.",
|
|
1105
|
+
"failed": "Test fehlgeschlagen",
|
|
1106
|
+
"stage": {
|
|
1107
|
+
"creating_branch": "Branch wird erstellt",
|
|
1108
|
+
"provisioning": "Umgebung wird bereitgestellt",
|
|
1109
|
+
"tearing_down": "Umgebung wird abgebaut",
|
|
1110
|
+
"deleting_branch": "Branch wird gelöscht",
|
|
1111
|
+
"done": "fertig"
|
|
1112
|
+
}
|
|
1096
1113
|
}
|
|
1097
1114
|
},
|
|
1098
1115
|
"agentConfig": {
|
|
@@ -3368,6 +3385,11 @@
|
|
|
3368
3385
|
"title": "Dieses Fragment löschen?",
|
|
3369
3386
|
"body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
|
|
3370
3387
|
},
|
|
3388
|
+
"confirmUnlinkSource": {
|
|
3389
|
+
"title": "Diese Quelle trennen?",
|
|
3390
|
+
"body": "„{repo}“ und die davon synchronisierten Richtlinien-Fragmente werden entfernt. Du kannst sie später erneut verknüpfen.",
|
|
3391
|
+
"confirm": "Trennen"
|
|
3392
|
+
},
|
|
3371
3393
|
"unavailable": "Die Prompt-Fragment-Bibliothek ist für dieses Deployment nicht aktiviert."
|
|
3372
3394
|
},
|
|
3373
3395
|
"brainstorm": {
|
|
@@ -3781,7 +3803,11 @@
|
|
|
3781
3803
|
"visual_pipeline_no_frontend": "Kein Frontend zum Testen",
|
|
3782
3804
|
"model_policy_blocked": "Modell durch Kontorichtlinie blockiert",
|
|
3783
3805
|
"model_policy_unsupported": "Modellrichtlinie hier nicht verfügbar",
|
|
3784
|
-
"deployer_required_before_tester": "Füge einen Deployer vor dem Tester hinzu"
|
|
3806
|
+
"deployer_required_before_tester": "Füge einen Deployer vor dem Tester hinzu",
|
|
3807
|
+
"env_test_not_a_frame": "Kein Dienst",
|
|
3808
|
+
"env_test_infraless": "Nichts zu testen",
|
|
3809
|
+
"env_test_not_provisionable": "Umgebungs-Handler nicht konfiguriert",
|
|
3810
|
+
"env_test_no_vcs": "Git-Anbieter nicht verbunden"
|
|
3785
3811
|
},
|
|
3786
3812
|
"fallbackMessage": "Diese Aktion steht im Konflikt mit dem aktuellen Zustand.",
|
|
3787
3813
|
"providersUnconfigured": {
|
|
@@ -3838,7 +3864,9 @@
|
|
|
3838
3864
|
"userIdPlaceholder": "Benutzer-ID (usr_...)",
|
|
3839
3865
|
"slackIdPlaceholder": "Slack-Mitglieds-ID (U...)",
|
|
3840
3866
|
"add": "Mitglied hinzufügen",
|
|
3841
|
-
"save": "Zuordnung speichern"
|
|
3867
|
+
"save": "Zuordnung speichern",
|
|
3868
|
+
"incompleteTitle": "Unvollständige Mitgliederzeile",
|
|
3869
|
+
"incompleteBody": "Gib vor dem Speichern sowohl die Benutzer-ID als auch die Slack-Mitglieds-ID ein oder entferne die Zeile."
|
|
3842
3870
|
},
|
|
3843
3871
|
"routable": {
|
|
3844
3872
|
"merge_review": "Merge-Review",
|
package/i18n/locales/en.json
CHANGED
|
@@ -475,7 +475,11 @@
|
|
|
475
475
|
"visual_pipeline_no_frontend": "No frontend to test",
|
|
476
476
|
"model_policy_blocked": "Model blocked by account policy",
|
|
477
477
|
"model_policy_unsupported": "Model policy not available here",
|
|
478
|
-
"deployer_required_before_tester": "Add a Deployer before the Tester"
|
|
478
|
+
"deployer_required_before_tester": "Add a Deployer before the Tester",
|
|
479
|
+
"env_test_not_a_frame": "Not a service",
|
|
480
|
+
"env_test_infraless": "Nothing to test",
|
|
481
|
+
"env_test_not_provisionable": "Environment handler not configured",
|
|
482
|
+
"env_test_no_vcs": "Git provider not connected"
|
|
479
483
|
},
|
|
480
484
|
"fallbackMessage": "This action conflicts with the current state.",
|
|
481
485
|
"providersUnconfigured": {
|
|
@@ -837,6 +841,23 @@
|
|
|
837
841
|
"title": "Compose environment setup",
|
|
838
842
|
"hint": "Configure the recipe, preflights, and handler so the Deployer provisions this stack.",
|
|
839
843
|
"open": "Open wizard"
|
|
844
|
+
},
|
|
845
|
+
"envTest": {
|
|
846
|
+
"title": "Test environment creation",
|
|
847
|
+
"hint": "Runs the whole lifecycle against a throwaway branch: create branch, provision, tear down, delete branch.",
|
|
848
|
+
"start": "Test environment creation",
|
|
849
|
+
"stop": "Stop",
|
|
850
|
+
"infraless": "Configure a provision type above to test environment creation.",
|
|
851
|
+
"running": "Testing: {stage}",
|
|
852
|
+
"succeeded": "Test passed: the environment was created and torn down, and the branch was deleted.",
|
|
853
|
+
"failed": "Test failed",
|
|
854
|
+
"stage": {
|
|
855
|
+
"creating_branch": "creating branch",
|
|
856
|
+
"provisioning": "provisioning environment",
|
|
857
|
+
"tearing_down": "tearing down environment",
|
|
858
|
+
"deleting_branch": "deleting branch",
|
|
859
|
+
"done": "done"
|
|
860
|
+
}
|
|
840
861
|
}
|
|
841
862
|
},
|
|
842
863
|
"agentConfig": {
|
|
@@ -2986,7 +3007,9 @@
|
|
|
2986
3007
|
"userIdPlaceholder": "User id (usr_...)",
|
|
2987
3008
|
"slackIdPlaceholder": "Slack member id (U...)",
|
|
2988
3009
|
"add": "Add member",
|
|
2989
|
-
"save": "Save map"
|
|
3010
|
+
"save": "Save map",
|
|
3011
|
+
"incompleteTitle": "Incomplete member row",
|
|
3012
|
+
"incompleteBody": "Fill in both the user id and the Slack member id, or remove the row, before saving."
|
|
2990
3013
|
},
|
|
2991
3014
|
"routable": {
|
|
2992
3015
|
"merge_review": "Merge review",
|
|
@@ -4319,6 +4342,11 @@
|
|
|
4319
4342
|
"title": "Delete this fragment?",
|
|
4320
4343
|
"body": "\"{name}\" will be removed. This can't be undone."
|
|
4321
4344
|
},
|
|
4345
|
+
"confirmUnlinkSource": {
|
|
4346
|
+
"title": "Unlink this source?",
|
|
4347
|
+
"body": "\"{repo}\" and the guideline fragments it synced will be removed. You can re-link it later.",
|
|
4348
|
+
"confirm": "Unlink"
|
|
4349
|
+
},
|
|
4322
4350
|
"unavailable": "The prompt-fragment library isn't enabled for this deployment."
|
|
4323
4351
|
},
|
|
4324
4352
|
"sandbox": {
|
package/i18n/locales/es.json
CHANGED
|
@@ -436,7 +436,11 @@
|
|
|
436
436
|
"visual_pipeline_no_frontend": "No hay frontend que probar",
|
|
437
437
|
"model_policy_blocked": "Modelo bloqueado por la política de la cuenta",
|
|
438
438
|
"model_policy_unsupported": "La política de modelos no está disponible aquí",
|
|
439
|
-
"deployer_required_before_tester": "Añade un Deployer antes del Tester"
|
|
439
|
+
"deployer_required_before_tester": "Añade un Deployer antes del Tester",
|
|
440
|
+
"env_test_not_a_frame": "No es un servicio",
|
|
441
|
+
"env_test_infraless": "Nada que probar",
|
|
442
|
+
"env_test_not_provisionable": "Gestor de entorno no configurado",
|
|
443
|
+
"env_test_no_vcs": "Proveedor de Git no conectado"
|
|
440
444
|
},
|
|
441
445
|
"fallbackMessage": "Esta acción entra en conflicto con el estado actual.",
|
|
442
446
|
"providersUnconfigured": {
|
|
@@ -783,6 +787,23 @@
|
|
|
783
787
|
"title": "Configuración de entorno Compose",
|
|
784
788
|
"hint": "Configura la receta, las comprobaciones previas y el gestor para que el Deployer aprovisione este stack.",
|
|
785
789
|
"open": "Abrir asistente"
|
|
790
|
+
},
|
|
791
|
+
"envTest": {
|
|
792
|
+
"title": "Probar la creación del entorno",
|
|
793
|
+
"hint": "Ejecuta todo el ciclo de vida sobre una rama desechable: crear rama, aprovisionar, desmontar, eliminar rama.",
|
|
794
|
+
"start": "Probar la creación del entorno",
|
|
795
|
+
"stop": "Detener",
|
|
796
|
+
"infraless": "Configura arriba un tipo de aprovisionamiento para probar la creación del entorno.",
|
|
797
|
+
"running": "Probando: {stage}",
|
|
798
|
+
"succeeded": "Prueba superada: el entorno se creó y se desmontó, y la rama se eliminó.",
|
|
799
|
+
"failed": "La prueba falló",
|
|
800
|
+
"stage": {
|
|
801
|
+
"creating_branch": "creando rama",
|
|
802
|
+
"provisioning": "aprovisionando entorno",
|
|
803
|
+
"tearing_down": "desmontando entorno",
|
|
804
|
+
"deleting_branch": "eliminando rama",
|
|
805
|
+
"done": "listo"
|
|
806
|
+
}
|
|
786
807
|
}
|
|
787
808
|
},
|
|
788
809
|
"agentConfig": {
|
|
@@ -2899,7 +2920,9 @@
|
|
|
2899
2920
|
"userIdPlaceholder": "Id de usuario (usr_...)",
|
|
2900
2921
|
"slackIdPlaceholder": "Id de miembro de Slack (U...)",
|
|
2901
2922
|
"add": "Anadir miembro",
|
|
2902
|
-
"save": "Guardar mapa"
|
|
2923
|
+
"save": "Guardar mapa",
|
|
2924
|
+
"incompleteTitle": "Fila de miembro incompleta",
|
|
2925
|
+
"incompleteBody": "Antes de guardar, completa tanto el id de usuario como el id de miembro de Slack, o elimina la fila."
|
|
2903
2926
|
},
|
|
2904
2927
|
"routable": {
|
|
2905
2928
|
"merge_review": "Revision de fusion",
|
|
@@ -4152,6 +4175,11 @@
|
|
|
4152
4175
|
"title": "¿Eliminar este fragmento?",
|
|
4153
4176
|
"body": "Se eliminará \"{name}\". Esta acción no se puede deshacer."
|
|
4154
4177
|
},
|
|
4178
|
+
"confirmUnlinkSource": {
|
|
4179
|
+
"title": "¿Desvincular esta fuente?",
|
|
4180
|
+
"body": "«{repo}» y los fragmentos de directrices que sincronizó se eliminarán. Puedes volver a vincularla más tarde.",
|
|
4181
|
+
"confirm": "Desvincular"
|
|
4182
|
+
},
|
|
4155
4183
|
"unavailable": "La biblioteca de fragmentos de prompt no está habilitada en esta implementación."
|
|
4156
4184
|
},
|
|
4157
4185
|
"sandbox": {
|
package/i18n/locales/fr.json
CHANGED
|
@@ -436,7 +436,11 @@
|
|
|
436
436
|
"visual_pipeline_no_frontend": "Aucun frontend à tester",
|
|
437
437
|
"model_policy_blocked": "Modèle bloqué par la politique du compte",
|
|
438
438
|
"model_policy_unsupported": "La politique de modèles n'est pas disponible ici",
|
|
439
|
-
"deployer_required_before_tester": "Ajoutez un Deployer avant le Testeur"
|
|
439
|
+
"deployer_required_before_tester": "Ajoutez un Deployer avant le Testeur",
|
|
440
|
+
"env_test_not_a_frame": "Pas un service",
|
|
441
|
+
"env_test_infraless": "Rien à tester",
|
|
442
|
+
"env_test_not_provisionable": "Gestionnaire d'environnement non configuré",
|
|
443
|
+
"env_test_no_vcs": "Fournisseur Git non connecté"
|
|
440
444
|
},
|
|
441
445
|
"fallbackMessage": "Cette action est en conflit avec l’état actuel.",
|
|
442
446
|
"providersUnconfigured": {
|
|
@@ -783,6 +787,23 @@
|
|
|
783
787
|
"title": "Configuration d'environnement Compose",
|
|
784
788
|
"hint": "Configurez la recette, les vérifications préalables et le gestionnaire pour que le Deployer provisionne cette stack.",
|
|
785
789
|
"open": "Ouvrir l'assistant"
|
|
790
|
+
},
|
|
791
|
+
"envTest": {
|
|
792
|
+
"title": "Tester la création de l'environnement",
|
|
793
|
+
"hint": "Exécute tout le cycle de vie sur une branche jetable : créer la branche, provisionner, démonter, supprimer la branche.",
|
|
794
|
+
"start": "Tester la création de l'environnement",
|
|
795
|
+
"stop": "Arrêter",
|
|
796
|
+
"infraless": "Configurez un type de provisionnement ci-dessus pour tester la création de l'environnement.",
|
|
797
|
+
"running": "Test en cours : {stage}",
|
|
798
|
+
"succeeded": "Test réussi : l'environnement a été créé puis démonté, et la branche a été supprimée.",
|
|
799
|
+
"failed": "Échec du test",
|
|
800
|
+
"stage": {
|
|
801
|
+
"creating_branch": "création de la branche",
|
|
802
|
+
"provisioning": "provisionnement de l'environnement",
|
|
803
|
+
"tearing_down": "démontage de l'environnement",
|
|
804
|
+
"deleting_branch": "suppression de la branche",
|
|
805
|
+
"done": "terminé"
|
|
806
|
+
}
|
|
786
807
|
}
|
|
787
808
|
},
|
|
788
809
|
"agentConfig": {
|
|
@@ -2899,7 +2920,9 @@
|
|
|
2899
2920
|
"userIdPlaceholder": "Id utilisateur (usr_...)",
|
|
2900
2921
|
"slackIdPlaceholder": "Id de membre Slack (U...)",
|
|
2901
2922
|
"add": "Ajouter un membre",
|
|
2902
|
-
"save": "Enregistrer la carte"
|
|
2923
|
+
"save": "Enregistrer la carte",
|
|
2924
|
+
"incompleteTitle": "Ligne de membre incomplète",
|
|
2925
|
+
"incompleteBody": "Avant d’enregistrer, renseignez l’id utilisateur et l’id de membre Slack, ou supprimez la ligne."
|
|
2903
2926
|
},
|
|
2904
2927
|
"routable": {
|
|
2905
2928
|
"merge_review": "Revue de fusion",
|
|
@@ -4152,6 +4175,11 @@
|
|
|
4152
4175
|
"title": "Supprimer ce fragment ?",
|
|
4153
4176
|
"body": "\"{name}\" sera supprimé. Cette action est irréversible."
|
|
4154
4177
|
},
|
|
4178
|
+
"confirmUnlinkSource": {
|
|
4179
|
+
"title": "Dissocier cette source ?",
|
|
4180
|
+
"body": "« {repo} » et les fragments de directives qu’elle a synchronisés seront supprimés. Vous pourrez la relier plus tard.",
|
|
4181
|
+
"confirm": "Dissocier"
|
|
4182
|
+
},
|
|
4155
4183
|
"unavailable": "La bibliothèque de fragments de prompt n'est pas activée pour ce déploiement."
|
|
4156
4184
|
},
|
|
4157
4185
|
"sandbox": {
|