@cat-factory/app 0.239.0 → 0.241.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.
Files changed (36) hide show
  1. package/app/components/documents/DocumentImportModal.vue +2 -0
  2. package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
  3. package/app/components/documents/DocumentSyncState.logic.ts +38 -0
  4. package/app/components/documents/DocumentSyncState.vue +181 -0
  5. package/app/components/documents/TaskContextDocs.vue +21 -10
  6. package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
  7. package/app/components/layout/AccountAuditLog.logic.ts +101 -0
  8. package/app/components/layout/AccountAuditLog.vue +148 -0
  9. package/app/components/layout/AccountTeamSettings.vue +39 -0
  10. package/app/components/panels/MergerResultView.vue +1 -0
  11. package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
  12. package/app/components/panels/StepToolServers.logic.ts +38 -0
  13. package/app/components/panels/StepToolServers.vue +28 -8
  14. package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
  15. package/app/composables/api/accounts.ts +12 -0
  16. package/app/composables/api/documents.ts +9 -0
  17. package/app/composables/useDocumentFreshness.ts +111 -0
  18. package/app/stores/accounts.audit.spec.ts +74 -0
  19. package/app/stores/accounts.ts +75 -0
  20. package/app/stores/board/moveRefusal.spec.ts +40 -0
  21. package/app/stores/board/moveRefusal.ts +34 -0
  22. package/app/stores/board/placement.ts +6 -1
  23. package/app/stores/documents.spec.ts +156 -0
  24. package/app/stores/documents.ts +14 -0
  25. package/app/types/documents.ts +4 -0
  26. package/i18n/locales/de.json +73 -3
  27. package/i18n/locales/en.json +73 -3
  28. package/i18n/locales/es.json +73 -3
  29. package/i18n/locales/fr.json +73 -3
  30. package/i18n/locales/he.json +73 -3
  31. package/i18n/locales/it.json +73 -3
  32. package/i18n/locales/ja.json +73 -3
  33. package/i18n/locales/pl.json +73 -3
  34. package/i18n/locales/tr.json +73 -3
  35. package/i18n/locales/uk.json +73 -3
  36. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
3
  import { useUpsertList } from '~/composables/useUpsertList'
4
+ import type { AuditEventWire } from '@cat-factory/contracts'
4
5
  import type {
5
6
  Account,
6
7
  AccountInvitation,
@@ -113,6 +114,73 @@ export const useAccountsStore = defineStore(
113
114
  return updated
114
115
  }
115
116
 
117
+ // ---- audit log + session revocation -----------------------------------
118
+ // The audit log is a paginated, append-only feed, so it is kept OUT of `useUpsertList`: the
119
+ // rows never change and never arrive out of band, and a keyed upsert list would quietly
120
+ // reorder a page whose whole meaning is its order. Pages are appended in the order the server
121
+ // served them, and `auditCursor` is opaque — it round-trips verbatim, never inspected.
122
+ const auditEvents = ref<AuditEventWire[]>([])
123
+ const auditCursor = ref<string | null>(null)
124
+ const auditLoading = ref(false)
125
+
126
+ /**
127
+ * The audit feed has fallen behind something this session did. Set by the writers below and
128
+ * cleared by whoever reloads; the audit viewer watches it.
129
+ *
130
+ * A flag rather than a reload, because reloading here is what conflated two outcomes: the
131
+ * revocation and the feed refresh are separate calls that fail separately, and awaiting the
132
+ * second inside the first reported a revocation that HAD succeeded as "could not sign the
133
+ * member out" whenever the read failed after it. It also fired on surfaces with no audit
134
+ * panel rendered (basic mode, and any account whose deployment wires no audit store), paying
135
+ * for a read nothing was going to show and turning its 503 into an error about the write.
136
+ *
137
+ * The viewer owns the reload instead, which is where it belongs: it already distinguishes a
138
+ * failed page from an empty one, and a refresh failure now renders in that slot rather than
139
+ * as a false report about the revocation.
140
+ */
141
+ const auditStale = ref(false)
142
+
143
+ /**
144
+ * Load the newest page, replacing whatever was held. Used on open and on refresh, so a reader
145
+ * is never shown a feed spliced from two different moments.
146
+ */
147
+ async function loadAuditEvents(accountId: string) {
148
+ auditLoading.value = true
149
+ // Cleared on ATTEMPT, not on success. A failed reload is reported by the viewer's own error
150
+ // slot, and leaving the flag set would re-trigger the watch that just failed.
151
+ auditStale.value = false
152
+ try {
153
+ const page = await api.listAuditEvents(accountId)
154
+ auditEvents.value = page.events
155
+ auditCursor.value = page.nextCursor
156
+ } finally {
157
+ auditLoading.value = false
158
+ }
159
+ }
160
+
161
+ /** Append the next (older) page. A no-op at the end of the log. */
162
+ async function loadMoreAuditEvents(accountId: string) {
163
+ if (!auditCursor.value || auditLoading.value) return
164
+ auditLoading.value = true
165
+ try {
166
+ const page = await api.listAuditEvents(accountId, { cursor: auditCursor.value })
167
+ auditEvents.value = [...auditEvents.value, ...page.events]
168
+ auditCursor.value = page.nextCursor
169
+ } finally {
170
+ auditLoading.value = false
171
+ }
172
+ }
173
+
174
+ /**
175
+ * End every session a member holds. Their membership and roles are untouched, so the roster
176
+ * needs no patching — what changed is not visible in it, which is why the audit feed is
177
+ * marked stale instead: the revocation's only lasting trace is the row it wrote.
178
+ */
179
+ async function revokeMemberSessions(accountId: string, userId: string) {
180
+ await api.revokeMemberSessions(accountId, userId)
181
+ auditStale.value = true
182
+ }
183
+
116
184
  // ---- email sender connection -----------------------------------------
117
185
 
118
186
  const emailConnection = ref<EmailConnection | null>(null)
@@ -144,6 +212,10 @@ export const useAccountsStore = defineStore(
144
212
  ready,
145
213
  members,
146
214
  invitations,
215
+ auditEvents,
216
+ auditCursor,
217
+ auditLoading,
218
+ auditStale,
147
219
  emailConnection,
148
220
  emailConfigured,
149
221
  load,
@@ -155,6 +227,9 @@ export const useAccountsStore = defineStore(
155
227
  invite,
156
228
  revokeInvite,
157
229
  setMemberRoles,
230
+ loadAuditEvents,
231
+ loadMoreAuditEvents,
232
+ revokeMemberSessions,
158
233
  loadEmailConnection,
159
234
  connectEmail,
160
235
  disconnectEmail,
@@ -0,0 +1,40 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { describe, expect, it } from 'vitest'
3
+ import en from '../../../i18n/locales/en.json'
4
+ import { moveRefusalKey } from './moveRefusal'
5
+
6
+ /** The thrown shape the contract client produces for a refused reparent (`ApiError.body`). */
7
+ const refused = (reason: string) => ({
8
+ body: { error: { code: 'forbidden', details: { reason } } },
9
+ })
10
+
11
+ const REASONS: RiskPolicySelectionRefusal[] = [
12
+ 'relaxes_role_sandbox',
13
+ 'relaxes_role_submission_allowlist',
14
+ 'relaxes_role_class_rule',
15
+ ]
16
+
17
+ describe('moveRefusalKey', () => {
18
+ it('maps every refusal reason to a key the catalog actually holds', () => {
19
+ // Derived from the contracts union rather than a list written here, so a new reason fails
20
+ // this rather than silently reaching the user as the backend's untranslated English.
21
+ const catalog = (en as { board: { toast: { moveRefused: Record<string, string> } } }).board
22
+ .toast.moveRefused
23
+ for (const reason of REASONS) {
24
+ const key = moveRefusalKey(refused(reason))
25
+ expect(key, `${reason} has no key`).toBe(`board.toast.moveRefused.${reason}`)
26
+ expect(catalog[reason], `${reason} has no copy`).toBeTruthy()
27
+ }
28
+ // And nothing else: copy for a reason the backend cannot send is copy nobody translates for
29
+ // a purpose.
30
+ expect(Object.keys(catalog).sort()).toEqual([...REASONS].sort())
31
+ })
32
+
33
+ it('falls back to the raw message for anything else', () => {
34
+ // A 403 the guard did not raise, a network fault, a reason a newer backend knows and this
35
+ // build does not: the backend's own prose is the honest last resort, not a wrong translation.
36
+ expect(moveRefusalKey(refused('some_future_reason'))).toBeNull()
37
+ expect(moveRefusalKey({ body: { error: { code: 'forbidden' } } })).toBeNull()
38
+ expect(moveRefusalKey(new Error('Network down'))).toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,34 @@
1
+ import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
2
+ import { apiErrorReason } from '~/composables/api/errors'
3
+
4
+ /**
5
+ * The translated description for a reparent the backend refused on merge-preset grounds, or
6
+ * `null` when the failure is anything else.
7
+ *
8
+ * A cross-home drag carries a task into another workspace's preset library, which re-decides
9
+ * whether its runs are sandboxed for the mover's role (ADR 0037), so the backend refuses one that
10
+ * would drop a restriction they are under. That refusal is a condition a person can act on, and
11
+ * the backend does not localize prose: it emits the machine-readable `details.reason` and the SPA
12
+ * maps it here, exactly as `usePipelineErrorToast` maps a conflict's. Without this the drag toast
13
+ * showed the raw English `ForbiddenError` message to every locale.
14
+ *
15
+ * The copy is deliberately NOT the picker's `riskPolicy.picker.refused.*`, which is worded for
16
+ * someone holding a control: this person picked no policy at all, and telling them about "the
17
+ * policy you picked" sends them looking for a picker they never touched.
18
+ *
19
+ * Keyed on the contracts union so a renamed reason fails the typecheck rather than silently
20
+ * falling through to the untranslated prose. An unrecognised reason returns `null` and the caller
21
+ * falls back to the backend's own message, which is the honest last resort.
22
+ */
23
+ const MOVE_REFUSAL_KEY: Record<RiskPolicySelectionRefusal, string> = {
24
+ relaxes_role_sandbox: 'board.toast.moveRefused.relaxes_role_sandbox',
25
+ relaxes_role_submission_allowlist: 'board.toast.moveRefused.relaxes_role_submission_allowlist',
26
+ relaxes_role_class_rule: 'board.toast.moveRefused.relaxes_role_class_rule',
27
+ }
28
+
29
+ /** The i18n key for a thrown reparent error's refusal reason, else `null`. */
30
+ export function moveRefusalKey(error: unknown): string | null {
31
+ const reason = apiErrorReason(error)
32
+ if (!reason) return null
33
+ return MOVE_REFUSAL_KEY[reason as RiskPolicySelectionRefusal] ?? null
34
+ }
@@ -2,6 +2,7 @@ import type { UpdateBlockInput } from '@cat-factory/contracts'
2
2
  import { useServicesStore } from '~/stores/services'
3
3
  import { useWorkspaceStore } from '~/stores/workspace'
4
4
  import { createBoardDependencies } from './dependencies'
5
+ import { moveRefusalKey } from './moveRefusal'
5
6
  import type { BoardWriteContext } from './context'
6
7
  import { UNDO_WINDOW_MS } from './context'
7
8
 
@@ -69,9 +70,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
69
70
  } catch (e) {
70
71
  b.parentId = prevParentId
71
72
  b.position = prevPosition
73
+ // A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
74
+ // can act on rather than a fault. The backend sends the machine-readable reason and no
75
+ // translated prose, so map it here; anything else keeps the raw message as the last resort.
76
+ const refusal = moveRefusalKey(e)
72
77
  toast.add({
73
78
  title: tr('board.toast.moveFailed'),
74
- description: e instanceof Error ? e.message : String(e),
79
+ description: refusal ? tr(refusal) : e instanceof Error ? e.message : String(e),
75
80
  icon: 'i-lucide-triangle-alert',
76
81
  color: 'error',
77
82
  })
@@ -0,0 +1,156 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { useDocumentsStore } from '~/stores/documents'
3
+ import { useWorkspaceStore } from '~/stores/workspace'
4
+ import type { DocumentFreshness, SourceDocument } from '~/types/domain'
5
+
6
+ // The document store's REFRESH half: what a person gets back when they ask a source whether the
7
+ // copy on the board is still the current one.
8
+ //
9
+ // Two things are worth pinning here rather than leaving to the component. The verdict is kept
10
+ // SEPARATE from the row because it is a statement about a moment, not a property of the projection,
11
+ // so an absent entry has to keep meaning "nobody has asked" (listing documents deliberately probes
12
+ // nothing). And a refresh that pulls a moved page has to reconcile the returned row into the same
13
+ // list every surface reads, or the panel goes on showing the title and excerpt import stored while
14
+ // claiming, one line below, that it just confirmed the newer revision.
15
+
16
+ function doc(over: Partial<SourceDocument> = {}): SourceDocument {
17
+ return {
18
+ source: 'figma',
19
+ externalId: 'file1:1-2',
20
+ title: 'Checkout flow',
21
+ url: 'https://figma.com/design/file1',
22
+ excerpt: 'Checkout',
23
+ linkedBlockId: 'task_1',
24
+ role: null,
25
+ docKind: null,
26
+ syncedAt: 1_000,
27
+ ...over,
28
+ }
29
+ }
30
+
31
+ const CONFIRMED: DocumentFreshness = { status: 'confirmed', version: 'v2', change: 'reimported' }
32
+
33
+ /** The store reads `useApi()` off the auto-import, so each test stubs just what it calls. */
34
+ function stubApi(over: Record<string, unknown>) {
35
+ vi.stubGlobal('useApi', () => ({
36
+ listDocuments: () => Promise.resolve([]),
37
+ ...over,
38
+ }))
39
+ }
40
+
41
+ beforeEach(() => {
42
+ useWorkspaceStore().workspaceId = 'ws1'
43
+ })
44
+
45
+ describe('documents store: manual refresh', () => {
46
+ it('reconciles the refreshed row into the list every surface reads', async () => {
47
+ stubApi({
48
+ listDocuments: () => Promise.resolve([doc()]),
49
+ refreshDocument: () =>
50
+ Promise.resolve({
51
+ document: doc({ title: 'Checkout flow v2', syncedAt: 2_000 }),
52
+ freshness: CONFIRMED,
53
+ }),
54
+ })
55
+ const store = useDocumentsStore()
56
+ await store.loadDocuments()
57
+
58
+ await store.refresh('figma', 'file1:1-2')
59
+
60
+ // Upserted by `(source, externalId)`, not appended: a refresh must not leave the old title
61
+ // sitting beside the new one in a picker keyed by the same document.
62
+ expect(store.documents).toHaveLength(1)
63
+ expect(store.documents[0]?.title).toBe('Checkout flow v2')
64
+ })
65
+
66
+ it('records the verdict beside the row, and reports "nobody asked" until someone does', async () => {
67
+ stubApi({
68
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
69
+ })
70
+ const store = useDocumentsStore()
71
+
72
+ // An unasked document has NO verdict. It must not read as unknown-and-therefore-suspect: a
73
+ // freshly imported page is fine, it simply has not been re-checked since.
74
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
75
+
76
+ await store.refresh('figma', 'file1:1-2')
77
+
78
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
79
+ // Stamped with WHEN it was reached, because a verdict never expires: what keeps an hour-old
80
+ // confirmation from rendering as the present state of a page that has had an hour to move is
81
+ // that the moment travels with it.
82
+ expect(store.freshnessFor('figma', 'file1:1-2')?.checkedAt).toBeTypeOf('number')
83
+ // Scoped to the document that was asked about, never to the source.
84
+ expect(store.freshnessFor('figma', 'other:9-9')).toBeUndefined()
85
+ })
86
+
87
+ it('never shows one board\u2019s verdict against another board\u2019s row', async () => {
88
+ // The same Figma file can be imported into two boards, and `(source, externalId)` is identical
89
+ // in both, so a verdict keyed by that pair alone would render board A's "confirmed, revision
90
+ // v2" against a board B row nobody has ever checked. That breaks the "absent means nobody has
91
+ // asked" rule in the one direction nothing can notice, since the wrong answer looks like a
92
+ // right one.
93
+ stubApi({
94
+ refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
95
+ })
96
+ const store = useDocumentsStore()
97
+ await store.refresh('figma', 'file1:1-2')
98
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
99
+
100
+ useWorkspaceStore().workspaceId = 'ws2'
101
+
102
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
103
+
104
+ // …and switching back does not lose it: the verdict stays true of the board it was asked on.
105
+ useWorkspaceStore().workspaceId = 'ws1'
106
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
107
+ })
108
+
109
+ it('does not merge a check that outlived a board switch into the new board\u2019s list', async () => {
110
+ // The list is the ACTIVE board's and is not keyed by board, so a row arriving after the switch
111
+ // would be a document from somewhere else appearing on a board that never imported it. The
112
+ // verdict is still filed, under the board that asked.
113
+ let resolve!: (v: unknown) => void
114
+ stubApi({
115
+ listDocuments: () => Promise.resolve([]),
116
+ refreshDocument: () =>
117
+ new Promise((res) => {
118
+ resolve = res
119
+ }),
120
+ })
121
+ const store = useDocumentsStore()
122
+ await store.loadDocuments()
123
+
124
+ const pending = store.refresh('figma', 'file1:1-2')
125
+ useWorkspaceStore().workspaceId = 'ws2'
126
+ resolve({ document: doc(), freshness: CONFIRMED })
127
+ await pending
128
+
129
+ expect(store.documents).toHaveLength(0)
130
+ useWorkspaceStore().workspaceId = 'ws1'
131
+ expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
132
+ })
133
+
134
+ it('reports in-flight state per document, and clears it when the source refuses', async () => {
135
+ let reject!: (e: Error) => void
136
+ stubApi({
137
+ refreshDocument: () =>
138
+ new Promise((_res, rej) => {
139
+ reject = rej
140
+ }),
141
+ })
142
+ const store = useDocumentsStore()
143
+
144
+ const pending = store.refresh('figma', 'file1:1-2')
145
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(true)
146
+ expect(store.isRefreshing('figma', 'other:9-9')).toBe(false)
147
+
148
+ reject(new Error('figma 429'))
149
+ await expect(pending).rejects.toThrow('figma 429')
150
+
151
+ // A failure that left the flag set would disable the button that is the whole remedy, and the
152
+ // person would have no way to try again.
153
+ expect(store.isRefreshing('figma', 'file1:1-2')).toBe(false)
154
+ expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
155
+ })
156
+ })
@@ -13,6 +13,7 @@ import type {
13
13
  SourceDocument,
14
14
  } from '~/types/domain'
15
15
  import { isConnectableSource } from '@cat-factory/contracts'
16
+ import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
16
17
  import { useSourceIntegration } from '~/composables/useSourceIntegration'
17
18
  import { useUpsertList } from '~/composables/useUpsertList'
18
19
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -56,6 +57,16 @@ export const useDocumentsStore = defineStore('documents', () => {
56
57
  })
57
58
  const loading = ref(false)
58
59
 
60
+ // The "is this still the current revision" half, in its own collaborator: a verdict is a
61
+ // statement about a moment rather than a property of a row, and `useDocumentFreshness` owns why
62
+ // the two must not merge.
63
+ const { refresh, freshnessFor, isRefreshing } = useDocumentFreshness({
64
+ workspaceId: () => workspace.workspaceId,
65
+ refresh: (source, externalId) =>
66
+ api.refreshDocument(workspace.requireId(), { source, externalId }),
67
+ onRefreshed: upsertDoc,
68
+ })
69
+
59
70
  // Workspace+DocKind template / exemplar role links (WS1). Loaded lazily when the management
60
71
  // panel opens; the full list of role-tagged documents across kinds.
61
72
  const roleLinks = ref<SourceDocument[]>([])
@@ -224,6 +235,9 @@ export const useDocumentsStore = defineStore('documents', () => {
224
235
  loadDocuments,
225
236
  resolveRef,
226
237
  importDocument,
238
+ refresh,
239
+ freshnessFor,
240
+ isRefreshing,
227
241
  search,
228
242
  plan,
229
243
  spawn,
@@ -15,6 +15,10 @@ export type {
15
15
  CredentialField,
16
16
  DocumentSourceDescriptor,
17
17
  DocumentConnection,
18
+ DocumentFreshness,
19
+ DocumentFreshnessChange,
20
+ DocumentFreshnessGap,
21
+ RefreshedDocumentView,
18
22
  SourceDocument,
19
23
  DocumentSearchResult,
20
24
  DocumentRefReason,
@@ -1832,6 +1832,7 @@
1832
1832
  "within_thresholds": "Jede Bewertung liegt innerhalb der {preset}-Schwellenwerte, daher wurde der PR automatisch gemergt.",
1833
1833
  "exceeded_thresholds": "{axes} überschritt die {preset}-Schwellenwerte, daher wartet der PR darauf, dass ein Mensch mergt.",
1834
1834
  "auto_merge_disabled": "Das {preset}-Preset sendet jeden PR an einen Menschen, daher wartet dieser auf Prüfung.",
1835
+ "no_policy_configured": "Für diesen Lauf galt keine Merge-Richtlinie, daher wird kein PR selbstständig gemergt. Diese Installation hat keine Merge-Preset-Bibliothek eingerichtet; das kann nur ein Betreiber ändern.",
1835
1836
  "no_rationale": "Der Merger hat den PR bewertet, aber keine Begründung gegeben, daher konnte dem Urteil zum automatischen Mergen nicht vertraut werden; der PR wartet darauf, dass ein Mensch mergt.",
1836
1837
  "no_assessment": "Der Merger hat keine parsbare Bewertung zurückgegeben, daher wartet der PR darauf, dass ein Mensch mergt.",
1837
1838
  "merge_failed": "Die Bewertungen lagen innerhalb der {preset}-Schwellenwerte, aber der automatische Merge konnte nicht abgeschlossen werden (zum Beispiel Branch-Schutz oder ein Konflikt), daher wartet der PR darauf, dass ein Mensch mergt.",
@@ -1997,6 +1998,15 @@
1997
1998
  "oauthTokenFailed": "war nicht verfügbar: die Verbindung liefert kein Zugriffstoken mehr.",
1998
1999
  "overBudget": "war nicht verfügbar: dieser Agent deklariert mehr Tool-Server, als ein Lauf mitführt.",
1999
2000
  "unknown": "war nicht verfügbar ({reason})."
2001
+ },
2002
+ "remedy": {
2003
+ "harnessUnsupported": "Führen Sie den Schritt auf einer Agenten-CLI mit MCP aus, oder erweitern Sie die Harness-Liste des Servers.",
2004
+ "transportUnsupported": "Deklarieren Sie dafür einen stdio-Server, oder führen Sie den Schritt auf einer Agenten-CLI aus, die HTTP-Server erreicht.",
2005
+ "missingSecret": "Hinterlegen Sie die genannte Zugangsinformation im Infrastruktur-Fenster unter den Capability-Zugangsdaten.",
2006
+ "reservedSecret": "Ändern Sie die Deklaration auf einen anderen Schlüssel; das Setzen dieser Variablen hilft gerade nicht.",
2007
+ "oauthNotConnected": "Verbinden Sie dieses Board im Infrastruktur-Fenster damit.",
2008
+ "oauthTokenFailed": "Verbinden Sie es im Infrastruktur-Fenster neu, oder warten Sie die Störung des Anbieters ab.",
2009
+ "overBudget": "Kürzen Sie, was der Agent deklariert, damit ein Lauf alles mitführen kann."
2000
2010
  }
2001
2011
  },
2002
2012
  "adherence": {
@@ -2204,7 +2214,9 @@
2204
2214
  },
2205
2215
  "members": {
2206
2216
  "title": "Mitglieder",
2207
- "empty": "Noch keine Mitglieder."
2217
+ "empty": "Noch keine Mitglieder.",
2218
+ "revokeSessions": "Auf allen Geräten abmelden",
2219
+ "sessionsRevoked": "Auf allen Geräten abgemeldet"
2208
2220
  },
2209
2221
  "invite": {
2210
2222
  "title": "Ein Teammitglied einladen",
@@ -2243,7 +2255,8 @@
2243
2255
  "sendInvite": "Einladung konnte nicht gesendet werden",
2244
2256
  "revokeInvite": "Einladung konnte nicht widerrufen werden",
2245
2257
  "connectEmail": "E-Mail-Absender konnte nicht verbunden werden",
2246
- "disconnectEmail": "E-Mail-Absender konnte nicht getrennt werden"
2258
+ "disconnectEmail": "E-Mail-Absender konnte nicht getrennt werden",
2259
+ "revokeSessions": "Mitglied konnte nicht abgemeldet werden"
2247
2260
  },
2248
2261
  "emailNoun": "E-Mail-Versand"
2249
2262
  },
@@ -2653,6 +2666,39 @@
2653
2666
  },
2654
2667
  "accountFoundational": {
2655
2668
  "intro": "Die gemeinsamen Fähigkeiten, die diese Organisation bereits betreibt - Dateiablage, Benachrichtigungen, Audit - samt ihren API-Verträgen. Jedes Board erbt sie, und einem Architekten wird gesagt, sie zu nutzen statt einen Neubau vorzuschlagen. Dienste, die dieses Deployment im Code registriert, werden hier ebenfalls geerbt und können per ID überschrieben oder unten abgewählt werden."
2669
+ },
2670
+ "auditLog": {
2671
+ "title": "Prüfprotokoll",
2672
+ "description": "Wer in diesem Konto was und wann geändert hat. Einträge werden nur angehängt und können weder bearbeitet noch gelöscht werden.",
2673
+ "empty": "Für dieses Konto wurde bisher nichts aufgezeichnet.",
2674
+ "loadMore": "Ältere Einträge laden",
2675
+ "refresh": "Aktualisieren",
2676
+ "retiredAction": "hat eine Aktion ausgeführt, die diese Version nicht mehr kennt ({action})",
2677
+ "actors": {
2678
+ "system": "Das System",
2679
+ "apiKey": "API-Schlüssel {id}"
2680
+ },
2681
+ "values": {
2682
+ "none": "keine"
2683
+ },
2684
+ "actions": {
2685
+ "accountMemberAdded": "hat {target} als {roles} zum Konto hinzugefügt",
2686
+ "accountMemberRolesChanged": "hat die Rollen von {target} von {previousRoles} zu {roles} geändert",
2687
+ "accountBudgetChanged": "hat das monatliche Ausgabenlimit auf {limit} gesetzt",
2688
+ "accountSettingsChanged": "hat den Standard-Cloud-Anbieter auf {defaultCloudProvider} gesetzt",
2689
+ "accountInvitationCreated": "hat {email} als {roles} eingeladen",
2690
+ "accountInvitationRevoked": "hat die Einladung für {email} zurückgezogen",
2691
+ "accountInvitationAccepted": "hat die Einladung für {email} als {roles} angenommen",
2692
+ "accountMemberSessionsRevoked": "hat {target} auf allen Geräten abgemeldet",
2693
+ "workspaceMemberAdded": "hat {target} als {role} zu einem Board hinzugefügt",
2694
+ "workspaceMemberRoleChanged": "hat die Board-Rolle von {target} von {previousRole} zu {role} geändert",
2695
+ "workspaceMemberRemoved": "hat {target} aus einem Board entfernt (war {role})",
2696
+ "workspaceAccessModeChanged": "hat den Board-Zugriff auf {accessMode} gesetzt"
2697
+ },
2698
+ "errors": {
2699
+ "load": "Das Prüfprotokoll konnte nicht geladen werden.",
2700
+ "loadMore": "Ältere Einträge konnten nicht geladen werden"
2701
+ }
2656
2702
  }
2657
2703
  },
2658
2704
  "board": {
@@ -2670,7 +2716,12 @@
2670
2716
  "archived": "Archiviert: „{name}“",
2671
2717
  "restored": "Wiederhergestellt: „{name}“",
2672
2718
  "archiveFailed": "Dienst konnte nicht archiviert werden",
2673
- "restoreFailed": "Dienst konnte nicht wiederhergestellt werden"
2719
+ "restoreFailed": "Dienst konnte nicht wiederhergestellt werden",
2720
+ "moveRefused": {
2721
+ "relaxes_role_sandbox": "Die Ausführungen dieser Aufgabe laufen an ihrem jetzigen Ort für deine Rolle isoliert, die Merge-Richtlinie am Zielort jedoch nicht. Bitte eine Workspace-Administration, sie zu verschieben.",
2722
+ "relaxes_role_submission_allowlist": "Die Merge-Richtlinie am Zielort würde dich Änderungsarten mergen lassen, die dir hier verwehrt sind. Bitte eine Workspace-Administration, sie zu verschieben.",
2723
+ "relaxes_role_class_rule": "Die Merge-Richtlinie am Zielort merged Änderungen automatisch, die du hier prüfen musst. Bitte eine Workspace-Administration, sie zu verschieben."
2724
+ }
2674
2725
  },
2675
2726
  "repoTypes": {
2676
2727
  "service": "Service",
@@ -3840,6 +3891,25 @@
3840
3891
  "imported": "\"{title}\" importiert",
3841
3892
  "importFailed": "Import fehlgeschlagen"
3842
3893
  },
3894
+ "freshness": {
3895
+ "updated": "Aktualisiert am {when}",
3896
+ "refresh": "Auf Änderungen prüfen",
3897
+ "change": {
3898
+ "unchanged": "Stimmt mit der Quelle überein",
3899
+ "reimported": "Neuere Fassung übernommen",
3900
+ "revision_only": "Die Quelle hat sich geändert, diese Kopie jedoch nicht"
3901
+ },
3902
+ "checkedAt": "Geprüft am {when}",
3903
+ "revision": "Revision {version}",
3904
+ "notApplicable": "Diese Installation hat keinen Leser für diese Quelle, es gibt also nichts zum Abgleichen.",
3905
+ "gap": {
3906
+ "not_connected": "Nicht geprüft: dieser Workspace ist nicht mehr mit der Quelle verbunden.",
3907
+ "credentials_unreadable": "Nicht geprüft: diese Installation kann die Zugangsdaten der Quelle nicht lesen.",
3908
+ "unversioned": "Nicht geprüft: die Quelle veröffentlicht keine Revision zum Vergleich.",
3909
+ "source_unreachable": "Nicht geprüft: die Quelle war nicht erreichbar."
3910
+ },
3911
+ "refreshFailed": "Prüfung auf Änderungen fehlgeschlagen"
3912
+ },
3843
3913
  "connect": {
3844
3914
  "title": "Quelle verbinden",
3845
3915
  "sourceFallback": "Quelle",
@@ -171,7 +171,12 @@
171
171
  "archived": "Archived \"{name}\"",
172
172
  "restored": "Restored \"{name}\"",
173
173
  "archiveFailed": "Couldn't archive the service",
174
- "restoreFailed": "Couldn't restore the service"
174
+ "restoreFailed": "Couldn't restore the service",
175
+ "moveRefused": {
176
+ "relaxes_role_sandbox": "This task’s runs are sandboxed for your role where it is now, and the merge policy governing it where you are moving it is not. Ask a workspace admin to move it.",
177
+ "relaxes_role_submission_allowlist": "The merge policy where you are moving this task would let you land kinds of change it holds you back from here. Ask a workspace admin to move it.",
178
+ "relaxes_role_class_rule": "The merge policy where you are moving this task auto-merges changes you are held to review on it here. Ask a workspace admin to move it."
179
+ }
175
180
  },
176
181
  "repoTypes": {
177
182
  "service": "Service",
@@ -1351,6 +1356,7 @@
1351
1356
  "within_thresholds": "Every score is within the {preset} thresholds, so the PR was merged automatically.",
1352
1357
  "exceeded_thresholds": "{axes} exceeded the {preset} thresholds, so the PR is waiting for a human to merge.",
1353
1358
  "auto_merge_disabled": "The {preset} preset sends every PR to a human, so this one is waiting for review.",
1359
+ "no_policy_configured": "No merge policy governed this run, so no PR merges on its own. This deployment has no merge preset library set up, which an operator has to fix.",
1354
1360
  "no_rationale": "The merger scored the PR but gave no rationale, so the verdict could not be trusted to auto-merge; the PR is waiting for a human to merge.",
1355
1361
  "no_assessment": "The merger did not return a parseable assessment, so the PR is waiting for a human to merge.",
1356
1362
  "merge_failed": "The scores were within the {preset} thresholds, but the automatic merge could not complete (for example branch protection or a conflict), so the PR is waiting for a human to merge.",
@@ -1519,6 +1525,15 @@
1519
1525
  "oauthTokenFailed": "was not available: the connection stopped producing an access token.",
1520
1526
  "overBudget": "was not available: this agent declares more tool servers than one run carries.",
1521
1527
  "unknown": "was not available ({reason})."
1528
+ },
1529
+ "remedy": {
1530
+ "harnessUnsupported": "Run the step on an agent CLI that speaks MCP, or widen the server's harness list.",
1531
+ "transportUnsupported": "Declare a stdio server for it, or run the step on an agent CLI that reaches HTTP servers.",
1532
+ "missingSecret": "Set the credential it names under capability credentials, in the Infrastructure window.",
1533
+ "reservedSecret": "Change the declaration to ask for another key; setting that variable is exactly what will not help.",
1534
+ "oauthNotConnected": "Connect this board to it from the Infrastructure window.",
1535
+ "oauthTokenFailed": "Reconnect it from the Infrastructure window, or wait out the vendor's outage.",
1536
+ "overBudget": "Trim what the agent declares, so one run can carry all of it."
1522
1537
  }
1523
1538
  },
1524
1539
  "adherence": {
@@ -2127,7 +2142,9 @@
2127
2142
  },
2128
2143
  "members": {
2129
2144
  "title": "Members",
2130
- "empty": "No members yet."
2145
+ "empty": "No members yet.",
2146
+ "revokeSessions": "Sign out of every device",
2147
+ "sessionsRevoked": "Signed out of every device"
2131
2148
  },
2132
2149
  "invite": {
2133
2150
  "title": "Invite a teammate",
@@ -2166,7 +2183,8 @@
2166
2183
  "sendInvite": "Could not send invitation",
2167
2184
  "revokeInvite": "Could not revoke invitation",
2168
2185
  "connectEmail": "Could not connect email sender",
2169
- "disconnectEmail": "Could not disconnect email sender"
2186
+ "disconnectEmail": "Could not disconnect email sender",
2187
+ "revokeSessions": "Could not sign the member out"
2170
2188
  },
2171
2189
  "emailNoun": "email sending"
2172
2190
  },
@@ -2576,6 +2594,39 @@
2576
2594
  },
2577
2595
  "accountFoundational": {
2578
2596
  "intro": "The shared capabilities this organisation already runs - file storage, notifications, audit - with their API contracts. Every board inherits them, and an architect is told to consume them instead of proposing a rebuild. Services this deployment registers in code are inherited here too, and can be overridden by id or opted out of below."
2597
+ },
2598
+ "auditLog": {
2599
+ "title": "Audit log",
2600
+ "description": "Who changed what in this account, and when. Records are append-only and cannot be edited or deleted.",
2601
+ "empty": "Nothing has been recorded for this account yet.",
2602
+ "loadMore": "Load older entries",
2603
+ "refresh": "Refresh",
2604
+ "retiredAction": "performed an action this version no longer recognises ({action})",
2605
+ "actors": {
2606
+ "system": "The system",
2607
+ "apiKey": "API key {id}"
2608
+ },
2609
+ "values": {
2610
+ "none": "none"
2611
+ },
2612
+ "actions": {
2613
+ "accountMemberAdded": "added {target} to the account as {roles}",
2614
+ "accountMemberRolesChanged": "changed the roles of {target} from {previousRoles} to {roles}",
2615
+ "accountBudgetChanged": "set the monthly spending limit to {limit}",
2616
+ "accountSettingsChanged": "set the default cloud provider to {defaultCloudProvider}",
2617
+ "accountInvitationCreated": "invited {email} as {roles}",
2618
+ "accountInvitationRevoked": "revoked the invitation for {email}",
2619
+ "accountInvitationAccepted": "accepted the invitation for {email} as {roles}",
2620
+ "accountMemberSessionsRevoked": "signed {target} out of every device",
2621
+ "workspaceMemberAdded": "added {target} to a board as {role}",
2622
+ "workspaceMemberRoleChanged": "changed the board role of {target} from {previousRole} to {role}",
2623
+ "workspaceMemberRemoved": "removed {target} from a board (they were {role})",
2624
+ "workspaceAccessModeChanged": "set board access to {accessMode}"
2625
+ },
2626
+ "errors": {
2627
+ "load": "The audit log could not be loaded.",
2628
+ "loadMore": "Could not load older entries"
2629
+ }
2579
2630
  }
2580
2631
  },
2581
2632
  "settings": {
@@ -4360,6 +4411,25 @@
4360
4411
  "imported": "Imported \"{title}\"",
4361
4412
  "importFailed": "Import failed"
4362
4413
  },
4414
+ "freshness": {
4415
+ "updated": "Updated {when}",
4416
+ "refresh": "Check for changes",
4417
+ "change": {
4418
+ "unchanged": "Matches the source",
4419
+ "reimported": "Pulled the newer version",
4420
+ "revision_only": "The source moved on, but this copy is unchanged"
4421
+ },
4422
+ "checkedAt": "Checked {when}",
4423
+ "revision": "Revision {version}",
4424
+ "notApplicable": "This deployment has no reader for that source, so there is nothing to check against.",
4425
+ "gap": {
4426
+ "not_connected": "Not checked: this workspace is no longer connected to the source.",
4427
+ "credentials_unreadable": "Not checked: this deployment cannot read the source credentials.",
4428
+ "unversioned": "Not checked: the source publishes no revision to compare against.",
4429
+ "source_unreachable": "Not checked: the source could not be reached."
4430
+ },
4431
+ "refreshFailed": "Could not check for changes"
4432
+ },
4363
4433
  "connect": {
4364
4434
  "title": "Connect source",
4365
4435
  "sourceFallback": "Source",