@cat-factory/app 0.238.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 (37) 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/ReportsPanel.vue +72 -4
  12. package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
  13. package/app/components/panels/StepToolServers.logic.ts +38 -0
  14. package/app/components/panels/StepToolServers.vue +28 -8
  15. package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
  16. package/app/composables/api/accounts.ts +12 -0
  17. package/app/composables/api/documents.ts +9 -0
  18. package/app/composables/useDocumentFreshness.ts +111 -0
  19. package/app/stores/accounts.audit.spec.ts +74 -0
  20. package/app/stores/accounts.ts +75 -0
  21. package/app/stores/board/moveRefusal.spec.ts +40 -0
  22. package/app/stores/board/moveRefusal.ts +34 -0
  23. package/app/stores/board/placement.ts +6 -1
  24. package/app/stores/documents.spec.ts +156 -0
  25. package/app/stores/documents.ts +14 -0
  26. package/app/types/documents.ts +4 -0
  27. package/i18n/locales/de.json +79 -3
  28. package/i18n/locales/en.json +79 -3
  29. package/i18n/locales/es.json +79 -3
  30. package/i18n/locales/fr.json +79 -3
  31. package/i18n/locales/he.json +79 -3
  32. package/i18n/locales/it.json +79 -3
  33. package/i18n/locales/ja.json +79 -3
  34. package/i18n/locales/pl.json +79 -3
  35. package/i18n/locales/tr.json +79 -3
  36. package/i18n/locales/uk.json +79 -3
  37. package/package.json +2 -2
@@ -0,0 +1,148 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { apiErrorEnvelope } from '~/composables/api/errors'
4
+ import type { AuditEventWire } from '@cat-factory/contracts'
5
+ import { actorLabel, describeEvent } from './AccountAuditLog.logic'
6
+
7
+ // The account audit log: who did what, when, for the privileged actions an account admin is
8
+ // answerable for. Read-only by construction — the store exposes no mutation and the backend has
9
+ // no update or delete surface besides the retention sweep.
10
+ //
11
+ // The whole design rests on one rule: the backend records machine-readable FIELDS and never
12
+ // prose, because a row is persisted and English written today could never be re-rendered for a
13
+ // reader in another locale years later. So every sentence here is composed from a translated key
14
+ // plus the row's `details`. The composition itself lives in `AccountAuditLog.logic.ts`, where
15
+ // the two cases a happy-path render never reaches (a retired action, an unreadable `details`
16
+ // blob) can be asserted without mounting anything.
17
+ const props = defineProps<{ accountId: string }>()
18
+
19
+ const accounts = useAccountsStore()
20
+ const toast = useToast()
21
+ const { t } = useI18n()
22
+
23
+ const events = computed(() => accounts.auditEvents)
24
+ const hasMore = computed(() => accounts.auditCursor !== null)
25
+ const loading = computed(() => accounts.auditLoading)
26
+ /**
27
+ * The load FAILED, as distinct from an empty log. An audit viewer that renders a store outage as
28
+ * "nothing has happened" tells an admin the exact opposite of the truth, so the two states have
29
+ * separate renderings and this one never silently resolves to an empty list.
30
+ */
31
+ const loadError = ref<string | null>(null)
32
+
33
+ async function load() {
34
+ loadError.value = null
35
+ try {
36
+ await accounts.loadAuditEvents(props.accountId)
37
+ } catch (e) {
38
+ loadError.value = apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e))
39
+ }
40
+ }
41
+
42
+ async function loadMore() {
43
+ try {
44
+ await accounts.loadMoreAuditEvents(props.accountId)
45
+ } catch (e) {
46
+ toast.add({
47
+ title: t('layout.auditLog.errors.loadMore'),
48
+ description: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)),
49
+ icon: 'i-lucide-triangle-alert',
50
+ color: 'error',
51
+ })
52
+ }
53
+ }
54
+
55
+ onMounted(() => void load())
56
+ watch(
57
+ () => props.accountId,
58
+ (id) => {
59
+ if (id) void load()
60
+ },
61
+ )
62
+ /**
63
+ * Something elsewhere in the app wrote a row this feed does not have yet (today: an admin forcing
64
+ * a member's sessions to end, whose only lasting trace IS that row).
65
+ *
66
+ * The reload lands here rather than in the writer for two reasons that are one reason: this
67
+ * component is the only place that knows the feed is being shown, and it is the only place that
68
+ * renders a failed read as a failed read. A writer that awaited the refresh itself would report
69
+ * its own success or failure by whether an unrelated GET succeeded.
70
+ */
71
+ watch(
72
+ () => accounts.auditStale,
73
+ (stale) => {
74
+ if (stale) void load()
75
+ },
76
+ )
77
+
78
+ /**
79
+ * The composition helpers, bound to this component's i18n instance. They take `t` as a parameter
80
+ * so the logic module stays pure and testable without an i18n runtime; binding here keeps the
81
+ * template free of the plumbing.
82
+ */
83
+ const describe = (event: AuditEventWire) => describeEvent(event, t)
84
+ const actor = (event: AuditEventWire) => actorLabel(event, t)
85
+
86
+ /** Absolute local time: an audit reader is answering "when exactly", never "how long ago". */
87
+ function timestamp(at: number): string {
88
+ return new Date(at).toLocaleString()
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <section class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
94
+ <div class="mb-3 flex items-start justify-between gap-3">
95
+ <div>
96
+ <h3 class="font-semibold text-white">{{ t('layout.auditLog.title') }}</h3>
97
+ <p class="mt-1 text-slate-400">{{ t('layout.auditLog.description') }}</p>
98
+ </div>
99
+ <UButton
100
+ size="xs"
101
+ color="neutral"
102
+ variant="ghost"
103
+ icon="i-lucide-refresh-cw"
104
+ :loading="loading"
105
+ :aria-label="t('layout.auditLog.refresh')"
106
+ data-testid="audit-log-refresh"
107
+ @click="load()"
108
+ />
109
+ </div>
110
+
111
+ <!-- A failed load is NOT an empty log; it says so and offers the retry. -->
112
+ <p v-if="loadError" class="text-red-400" data-testid="audit-log-error">
113
+ {{ t('layout.auditLog.errors.load') }}
114
+ <span class="text-slate-400">{{ loadError }}</span>
115
+ </p>
116
+
117
+ <p v-else-if="events.length === 0 && !loading" class="text-slate-400">
118
+ {{ t('layout.auditLog.empty') }}
119
+ </p>
120
+
121
+ <ol v-else class="space-y-2" data-testid="audit-log-list">
122
+ <li
123
+ v-for="event in events"
124
+ :key="event.id"
125
+ class="rounded border border-slate-800 bg-slate-900/40 px-3 py-2"
126
+ >
127
+ <div class="flex flex-wrap items-baseline gap-x-2">
128
+ <span class="font-medium text-white">{{ actor(event) }}</span>
129
+ <span class="text-slate-300">{{ describe(event) }}</span>
130
+ </div>
131
+ <div class="mt-1 text-xs text-slate-500">{{ timestamp(event.at) }}</div>
132
+ </li>
133
+ </ol>
134
+
135
+ <UButton
136
+ v-if="hasMore && !loadError"
137
+ class="mt-3"
138
+ size="xs"
139
+ color="neutral"
140
+ variant="soft"
141
+ :loading="loading"
142
+ data-testid="audit-log-load-more"
143
+ @click="loadMore()"
144
+ >
145
+ {{ t('layout.auditLog.loadMore') }}
146
+ </UButton>
147
+ </section>
148
+ </template>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
3
3
  import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
+ import AccountAuditLog from '~/components/layout/AccountAuditLog.vue'
6
7
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
8
  import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
8
9
  import AccountPlatformAlertSettings from '~/components/layout/AccountPlatformAlertSettings.vue'
@@ -16,6 +17,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
16
17
  const props = defineProps<{ accountId: string }>()
17
18
 
18
19
  const accounts = useAccountsStore()
20
+ const uiMode = useUiModeStore()
19
21
  const auth = useAuthStore()
20
22
  const toast = useToast()
21
23
  const { t, te } = useI18n()
@@ -66,6 +68,20 @@ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
66
68
  }
67
69
  }
68
70
 
71
+ /**
72
+ * End every session a member holds. Their roles are untouched, so nothing in the roster changes —
73
+ * which is why the confirmation names the person: there is no visible after-state to check.
74
+ */
75
+ async function revokeSessions(userId: string, label: string) {
76
+ if (!(await confirmAction('revoke', label))) return
77
+ try {
78
+ await accounts.revokeMemberSessions(props.accountId, userId)
79
+ toast.add({ title: t('layout.accountTeam.members.sessionsRevoked'), icon: 'i-lucide-check' })
80
+ } catch (e) {
81
+ notifyError(t('layout.accountTeam.errors.revokeSessions'), e)
82
+ }
83
+ }
84
+
69
85
  function notifyError(title: string, e: unknown) {
70
86
  toast.add({
71
87
  title,
@@ -232,6 +248,20 @@ async function disconnectEmail() {
232
248
  <span v-else class="text-xs uppercase tracking-wide text-slate-400">
233
249
  {{ m.roles.join(', ') }}
234
250
  </span>
251
+ <!-- Offboarding: end every session this member holds, leaving their membership and
252
+ roles alone. Confirmed, because it is not undoable from here (the person simply
253
+ signs in again) and because it is the sort of thing a mis-click should not do. -->
254
+ <UButton
255
+ v-if="isAdmin"
256
+ size="xs"
257
+ color="neutral"
258
+ variant="ghost"
259
+ icon="i-lucide-log-out"
260
+ :title="t('layout.accountTeam.members.revokeSessions')"
261
+ :aria-label="t('layout.accountTeam.members.revokeSessions')"
262
+ data-testid="revoke-member-sessions"
263
+ @click="revokeSessions(m.userId, m.name || m.email || m.userId)"
264
+ />
235
265
  </li>
236
266
  <li v-if="accounts.members.length === 0" class="text-slate-500">
237
267
  {{ t('layout.accountTeam.members.empty') }}
@@ -355,5 +385,14 @@ async function disconnectEmail() {
355
385
  <section v-if="isAdmin">
356
386
  <AccountRunCredentialSettings :account-id="accountId" />
357
387
  </section>
388
+
389
+ <!-- The account audit log (admin-only, ADVANCED tier). Advanced rather than basic because
390
+ reading who changed what is a governance task, not part of the everyday delivery loop —
391
+ and unlike an override field there is no default it hides, so hiding it withholds nothing
392
+ a basic-tier user would otherwise be acting on. The backend gates it too; this only
393
+ decides whether the surface exists. -->
394
+ <section v-if="isAdmin && uiMode.isAdvanced">
395
+ <AccountAuditLog :account-id="accountId" />
396
+ </section>
358
397
  </div>
359
398
  </template>
@@ -64,6 +64,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
64
64
  within_thresholds: 'panels.mergerResult.reason.within_thresholds',
65
65
  exceeded_thresholds: 'panels.mergerResult.reason.exceeded_thresholds',
66
66
  auto_merge_disabled: 'panels.mergerResult.reason.auto_merge_disabled',
67
+ no_policy_configured: 'panels.mergerResult.reason.no_policy_configured',
67
68
  no_rationale: 'panels.mergerResult.reason.no_rationale',
68
69
  no_assessment: 'panels.mergerResult.reason.no_assessment',
69
70
  merge_failed: 'panels.mergerResult.reason.merge_failed',
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
+ import { lastCompleteRollupDay } from '@cat-factory/contracts'
4
5
  import type {
5
6
  ReportActivityDimension,
6
7
  ReportActivityRow,
@@ -89,6 +90,29 @@ const activityByDimension = computed<ReportActivityRow[]>(() => {
89
90
  return activity.byTaskType
90
91
  })
91
92
 
93
+ const DAY_MS = 24 * 60 * 60 * 1000
94
+
95
+ // How the window's SPEND half was answered. The long (TCO) windows read the durable
96
+ // cost-attribution rollup, which is only as fresh as the last retention sweep, so a rollup
97
+ // that has materialised NOTHING must not render as a quiet quarter and one whose watermark is
98
+ // well behind `now` must not render its empty tail as thrift. Same three states as the
99
+ // operator dashboard's daily run rollup.
100
+ //
101
+ // The lag is measured against `lastCompleteRollupDay(generatedAt)`, NOT against `generatedAt`
102
+ // itself, because that is what `rolledUpThrough` counts in: the newest day the sweep could
103
+ // possibly have finished by now. Measuring against the wall clock instead compares a day
104
+ // boundary with an instant, so the very same healthy rollup drifts from ~0h of apparent lag
105
+ // just after midnight to ~24h just before the next one, and any fixed threshold then turns the
106
+ // hour the report happened to be opened into a health verdict. One whole missed day of slack
107
+ // is deliberate: the sweep is a daily cron on one facade, so a single skipped firing is a
108
+ // hiccup the next pass heals, while two in a row is the wedge worth naming.
109
+ const rollupState = computed<'none' | 'stale' | 'current' | null>(() => {
110
+ const v = view.value
111
+ if (!v || v.source !== 'daily-rollup') return null
112
+ if (v.rolledUpThrough == null) return 'none'
113
+ return lastCompleteRollupDay(v.generatedAt) - v.rolledUpThrough > DAY_MS ? 'stale' : 'current'
114
+ })
115
+
92
116
  const maxTrend = computed(() => maxOf(view.value?.trend.points ?? [], trendMagnitude))
93
117
  const hasSpend = computed(() => (view.value?.totals.calls ?? 0) > 0)
94
118
  // Hoisted: every activity bar is scaled against the busiest slice in the SAME list, so this
@@ -246,6 +270,38 @@ watch(
246
270
  }}
247
271
  </p>
248
272
 
273
+ <!-- Which store answered, and how far it reaches. An un-materialised rollup and an
274
+ account that spent nothing produce the same empty breakdown. -->
275
+ <p
276
+ v-if="rollupState === 'none'"
277
+ class="rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-200"
278
+ data-testid="reports-rollup-none"
279
+ >
280
+ {{ t('reports.rollup.none') }}
281
+ </p>
282
+ <p
283
+ v-else-if="rollupState === 'stale'"
284
+ class="rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-200"
285
+ data-testid="reports-rollup-stale"
286
+ >
287
+ {{
288
+ t('reports.rollup.stale', {
289
+ date: d(new Date(view.rolledUpThrough ?? 0), 'short'),
290
+ })
291
+ }}
292
+ </p>
293
+ <p
294
+ v-else-if="rollupState === 'current'"
295
+ class="text-[11px] text-slate-500"
296
+ data-testid="reports-rollup-current"
297
+ >
298
+ {{
299
+ t('reports.rollup.current', {
300
+ date: d(new Date(view.rolledUpThrough ?? 0), 'short'),
301
+ })
302
+ }}
303
+ </p>
304
+
249
305
  <!-- Headline totals. A stat tile, not a chart: these are single numbers. -->
250
306
  <section>
251
307
  <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
@@ -352,10 +408,11 @@ watch(
352
408
  </section>
353
409
  </div>
354
410
 
355
- <!-- The TCO axes: what a repository and a ticket actually cost. Spend-only, like the
356
- pair above, because a run's activity is already sliced by the service that owns
357
- the repo and there is no second population to pair a ticket with. -->
358
- <div class="grid gap-6 md:grid-cols-2">
411
+ <!-- The TCO axes: what a repository, a ticket and a single run actually cost.
412
+ Spend-only, like the pair above, because a run's activity is already sliced by
413
+ the service that owns the repo and there is no second population to pair a
414
+ ticket with, and a run IS the unit activity counts. -->
415
+ <div class="grid gap-6 md:grid-cols-3">
359
416
  <section>
360
417
  <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
361
418
  {{ t('reports.spend.byRepo') }}
@@ -378,6 +435,17 @@ watch(
378
435
  :label-of="sliceLabel"
379
436
  />
380
437
  </section>
438
+ <section>
439
+ <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
440
+ {{ t('reports.spend.byRun') }}
441
+ </h2>
442
+ <ReportsSpendBreakdown
443
+ :rows="view.spend.byRun"
444
+ :currency="currency"
445
+ test-id="reports-spend-run"
446
+ :label-of="sliceLabel"
447
+ />
448
+ </section>
381
449
  </div>
382
450
 
383
451
  <!-- The shared axis: spend AND activity for the same grouping, side by side. -->
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { KNOWN_REASONS, REASON_KEY, reasonText } from './StepToolServers.logic'
2
+ import {
3
+ KNOWN_REASONS,
4
+ REASON_KEY,
5
+ REMEDY_KEY,
6
+ reasonText,
7
+ remedyText,
8
+ } from './StepToolServers.logic'
3
9
  import type { ToolServerUnavailableReason } from '~/types/toolServers'
4
10
 
5
11
  /**
@@ -49,6 +55,40 @@ describe('tool-server unavailability reasons', () => {
49
55
  })
50
56
  })
51
57
 
58
+ /**
59
+ * The remedy is the half an operator acts on. A diagnosis with no next step is where this surface
60
+ * started: the reason was already stated to the AGENT in its prompt, and stating it to a person
61
+ * changes nothing unless it also names what to change.
62
+ */
63
+ describe('tool-server unavailability remedies', () => {
64
+ it('gives every reason in the wire vocabulary a remedy of its own', () => {
65
+ expect(Object.keys(REMEDY_KEY).sort()).toEqual([...KNOWN_REASONS].sort())
66
+ })
67
+
68
+ it('never points two reasons at one remedy', () => {
69
+ // The vocabulary exists BECAUSE each member needs a different fix, so two members sharing a
70
+ // remedy line means either the copy is wrong or the split was.
71
+ const keys = Object.values(REMEDY_KEY)
72
+ expect(new Set(keys).size).toBe(keys.length)
73
+ })
74
+
75
+ it('never reuses a reason line as a remedy', () => {
76
+ // The two are rendered together. A remedy pointing at the reason's own key would render the
77
+ // diagnosis twice and read as advice.
78
+ const reasons = new Set(Object.values(REASON_KEY))
79
+ for (const key of Object.values(REMEDY_KEY)) expect(reasons.has(key)).toBe(false)
80
+ })
81
+
82
+ it('offers no remedy for a retired reason, rather than guessing one', () => {
83
+ // The build knows the code was recorded and not what it meant. Any remedy here would name a
84
+ // surface picked from a member the operator may never have hit, and the reason line already
85
+ // states the raw code, which is the whole of what is known.
86
+ for (const reason of ['legacy_reason', 'constructor', '__proto__']) {
87
+ expect(remedyText(reason as ToolServerUnavailableReason, (key) => key)).toBeNull()
88
+ }
89
+ })
90
+ })
91
+
52
92
  /** Every `t` call `reasonText` made, so the assertion is about the key it CHOSE, not the copy. */
53
93
  function render(reason: string): { key: string; params?: Record<string, unknown> }[] {
54
94
  const seen: { key: string; params?: Record<string, unknown> }[] = []
@@ -25,6 +25,29 @@ export const REASON_KEY: Record<ToolServerUnavailableReason, string> = {
25
25
  over_budget: 'panels.stepDetail.toolServers.reason.overBudget',
26
26
  }
27
27
 
28
+ /**
29
+ * The i18n key per reason for the REMEDY line: what an operator has to change to get the server
30
+ * wired next run. Exhaustive on the same vocabulary and for the same reason as {@link REASON_KEY}.
31
+ *
32
+ * Split from the reason rather than folded into one sentence because the two answer different
33
+ * questions and only one of them is stable: the reason states what the dispatch decided and is a
34
+ * fact about that run forever, while the remedy names a surface this deployment happens to offer
35
+ * (the Infrastructure window, a declaration in deployment code). The split is also the vocabulary's
36
+ * own justification, since every member exists precisely because it needs a DIFFERENT fix
37
+ * (`docs`' table in `backend/docs/mcp-tool-servers.md` is the same mapping for a reader who never
38
+ * opens the SPA); a reason with no remedy leaves the operator holding an accurate diagnosis and no
39
+ * next step, which is the state this surface was built to end.
40
+ */
41
+ export const REMEDY_KEY: Record<ToolServerUnavailableReason, string> = {
42
+ harness_unsupported: 'panels.stepDetail.toolServers.remedy.harnessUnsupported',
43
+ transport_unsupported: 'panels.stepDetail.toolServers.remedy.transportUnsupported',
44
+ missing_secret: 'panels.stepDetail.toolServers.remedy.missingSecret',
45
+ reserved_secret: 'panels.stepDetail.toolServers.remedy.reservedSecret',
46
+ oauth_not_connected: 'panels.stepDetail.toolServers.remedy.oauthNotConnected',
47
+ oauth_token_failed: 'panels.stepDetail.toolServers.remedy.oauthTokenFailed',
48
+ over_budget: 'panels.stepDetail.toolServers.remedy.overBudget',
49
+ }
50
+
28
51
  /** The reason vocabulary as the SCHEMA states it: what a parity assertion grades {@link REASON_KEY} against. */
29
52
  export const KNOWN_REASONS = toolServerUnavailableReasonSchema.options
30
53
 
@@ -56,3 +79,18 @@ export function reasonText(
56
79
  ? t(REASON_KEY[reason])
57
80
  : t('panels.stepDetail.toolServers.reason.unknown', { reason })
58
81
  }
82
+
83
+ /**
84
+ * Render the remedy for one reason, or `null` when there is none to give.
85
+ *
86
+ * `null` is the honest answer for a RETIRED member and the reason it is not folded into
87
+ * {@link reasonText}: this build knows the code was recorded and does not know what it meant, so it
88
+ * cannot name a surface to change without guessing which current member the operator should act
89
+ * on. The reason line already names the raw code, which is the whole of what is known.
90
+ */
91
+ export function remedyText(
92
+ reason: ToolServerUnavailableReason,
93
+ t: (key: string, params?: Record<string, unknown>) => string,
94
+ ): string | null {
95
+ return isKnownReason(reason) ? t(REMEDY_KEY[reason]) : null
96
+ }
@@ -15,8 +15,8 @@
15
15
  // the Infrastructure window is where declarations are read; rendering it here would put an empty
16
16
  // section on every step of every run to say nothing happened. The distinction absent-vs-empty is
17
17
  // still carried on the wire and answered by the debug API, which is where a reader asking it looks.
18
- import type { StepToolServers, ToolServerUnavailableReason } from '~/types/toolServers'
19
- import { reasonText } from '~/components/panels/StepToolServers.logic'
18
+ import type { StepToolServers } from '~/types/toolServers'
19
+ import { reasonText, remedyText } from '~/components/panels/StepToolServers.logic'
20
20
  import { agentKindMeta } from '~/utils/catalog'
21
21
 
22
22
  const props = defineProps<{
@@ -44,9 +44,19 @@ const dispatchedAs = computed(() =>
44
44
  : null,
45
45
  )
46
46
 
47
- /** Bind this component's i18n instance onto the pure mapping (see `StepToolServers.logic.ts`). */
48
- const describeReason = (reason: ToolServerUnavailableReason) =>
49
- reasonText(reason, (key, params) => t(key, params ?? {}))
47
+ /**
48
+ * Each dropped server with both halves of its answer resolved: WHY it was dropped, and what to
49
+ * change so the next run gets it. Bound here rather than called from the template so the pure
50
+ * mappings (`StepToolServers.logic.ts`) stay assertable without mounting a component, and so a
51
+ * retired member's absent remedy is decided once instead of on every re-render.
52
+ */
53
+ const drops = computed(() =>
54
+ props.toolServers.unavailable.map((server) => ({
55
+ ...server,
56
+ reasonText: reasonText(server.reason, (key, params) => t(key, params ?? {})),
57
+ remedy: remedyText(server.reason, (key, params) => t(key, params ?? {})),
58
+ })),
59
+ )
50
60
  </script>
51
61
 
52
62
  <template>
@@ -87,9 +97,9 @@ const describeReason = (reason: ToolServerUnavailableReason) =>
87
97
  </li>
88
98
  </ul>
89
99
 
90
- <ul v-if="toolServers.unavailable.length" class="mt-2 space-y-1">
100
+ <ul v-if="drops.length" class="mt-2 space-y-1.5">
91
101
  <li
92
- v-for="server in toolServers.unavailable"
102
+ v-for="server in drops"
93
103
  :key="server.id"
94
104
  data-testid="step-tool-server-unavailable"
95
105
  class="flex items-start gap-1.5 text-[12px] text-slate-300"
@@ -97,7 +107,17 @@ const describeReason = (reason: ToolServerUnavailableReason) =>
97
107
  <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400/80" />
98
108
  <span>
99
109
  <span class="font-medium text-slate-200">{{ server.label || server.id }}</span>
100
- <span class="text-slate-400"> {{ describeReason(server.reason) }}</span>
110
+ <span class="text-slate-400"> {{ server.reasonText }}</span>
111
+ <!--
112
+ Absent for a reason this build no longer recognises: it knows the code was recorded and
113
+ not what it meant, so there is no surface it can honestly send an operator to.
114
+ -->
115
+ <span
116
+ v-if="server.remedy"
117
+ data-testid="step-tool-server-remedy"
118
+ class="mt-0.5 block text-slate-500"
119
+ >{{ server.remedy }}</span
120
+ >
101
121
  </span>
102
122
  </li>
103
123
  </ul>
@@ -78,7 +78,13 @@ export function refusedRiskPolicySelections(input: {
78
78
  if (!from) return refusals
79
79
  const judge = (id: string, to: RiskPolicy | null) => {
80
80
  if (!to) return
81
- const refusal = refuseRiskPolicySelection({ from, to, actor: input.actor })
81
+ // The same actor on both sides: a picker only ever offers the library of the ONE workspace the
82
+ // task is homed in, so both policies are in force there and the editor is the same person to
83
+ // each. The two-sided shape exists for the cross-home move, which the picker cannot express.
84
+ const refusal = refuseRiskPolicySelection({
85
+ from: { policy: from, actor: input.actor },
86
+ to: { policy: to, actor: input.actor },
87
+ })
82
88
  if (refusal) refusals.set(id, refusal)
83
89
  }
84
90
  judge('', input.defaultPolicy)
@@ -7,9 +7,11 @@ import {
7
7
  getAccountSettingsContract,
8
8
  getEmailConnectionContract,
9
9
  listAccountMembersContract,
10
+ listAuditEventsContract,
10
11
  listAccountsContract,
11
12
  listInvitationsContract,
12
13
  revokeInvitationContract,
14
+ revokeMemberSessionsContract,
13
15
  setMemberRolesContract,
14
16
  testEmailContract,
15
17
  updateAccountContract,
@@ -42,6 +44,16 @@ export function accountsApi({ send }: ApiContext) {
42
44
  setMemberRoles: (accountId: string, userId: string, roles: AccountRole[]) =>
43
45
  send(setMemberRolesContract, { pathParams: { accountId, userId }, body: { roles } }),
44
46
 
47
+ // End every session a member holds, without touching their membership or roles: the
48
+ // offboarding lever for a departure or a lost device. Admin-only (enforced server-side).
49
+ revokeMemberSessions: (accountId: string, userId: string) =>
50
+ send(revokeMemberSessionsContract, { pathParams: { accountId, userId } }),
51
+
52
+ // One page of the account's audit log, newest first. `cursor` is opaque and comes straight
53
+ // back from a previous page; the server clamps `limit`.
54
+ listAuditEvents: (accountId: string, query: { cursor?: string; limit?: number } = {}) =>
55
+ send(listAuditEventsContract, { pathParams: { accountId }, queryParams: query }),
56
+
45
57
  // Invitations: invite teammates by email into an org account.
46
58
  listInvitations: (accountId: string) =>
47
59
  send(listInvitationsContract, { pathParams: { accountId } }),
@@ -9,6 +9,7 @@ import {
9
9
  listDocumentsContract,
10
10
  listDocumentSourcesContract,
11
11
  planDocumentContract,
12
+ refreshDocumentContract,
12
13
  resolveDocumentRefContract,
13
14
  searchDocumentsContract,
14
15
  spawnDocumentContract,
@@ -61,6 +62,14 @@ export function documentsApi({ send, ws }: ApiContext) {
61
62
  importDocument: (workspaceId: string, source: DocumentSourceKind, body: { ref: string }) =>
62
63
  send(importDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
63
64
 
65
+ // Re-confirm one stored document against its source now, pulling the new body if the page
66
+ // moved. Keyed by `(source, externalId)` in the BODY, because the target is a stored row
67
+ // rather than a provider surface, and an `externalId` carries slashes.
68
+ refreshDocument: (
69
+ workspaceId: string,
70
+ body: { source: DocumentSourceKind; externalId: string },
71
+ ) => send(refreshDocumentContract, { pathPrefix: ws(workspaceId), body }),
72
+
64
73
  searchDocumentSource: (workspaceId: string, source: DocumentSourceKind, query: string) =>
65
74
  send(searchDocumentsContract, {
66
75
  pathPrefix: ws(workspaceId),