@cat-factory/app 0.37.0 → 0.37.2
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/auth/LoginScreen.vue +2 -2
- package/app/components/layout/AccountTeamSettings.vue +2 -3
- package/app/composables/api/client.ts +11 -1
- package/app/composables/api/errors.spec.ts +53 -0
- package/app/composables/api/errors.ts +63 -0
- package/app/composables/usePipelineErrorToast.ts +5 -5
- package/app/composables/useSourceIntegration.ts +107 -0
- package/app/composables/useUpsertList.spec.ts +60 -0
- package/app/composables/useUpsertList.ts +57 -0
- package/app/stores/documents.ts +27 -62
- package/app/stores/notifications.ts +9 -6
- package/app/stores/personalSubscriptions.ts +2 -1
- package/app/stores/tasks.ts +25 -76
- package/package.json +2 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
3
4
|
|
|
4
5
|
const auth = useAuthStore()
|
|
5
6
|
|
|
@@ -37,8 +38,7 @@ async function submitPassword() {
|
|
|
37
38
|
if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
|
|
38
39
|
} catch (e) {
|
|
39
40
|
error.value =
|
|
40
|
-
(e
|
|
41
|
-
'Sign-in failed. Check your details and try again.'
|
|
41
|
+
apiErrorEnvelope(e)?.message ?? 'Sign-in failed. Check your details and try again.'
|
|
42
42
|
} finally {
|
|
43
43
|
busy.value = false
|
|
44
44
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
3
4
|
import type { AccountRole } from '~/types/domain'
|
|
4
5
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
5
6
|
|
|
@@ -39,9 +40,7 @@ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
|
|
|
39
40
|
function notifyError(title: string, e: unknown) {
|
|
40
41
|
toast.add({
|
|
41
42
|
title,
|
|
42
|
-
description:
|
|
43
|
-
(e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
|
|
44
|
-
(e instanceof Error ? e.message : String(e)),
|
|
43
|
+
description: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)),
|
|
45
44
|
icon: 'i-lucide-triangle-alert',
|
|
46
45
|
color: 'error',
|
|
47
46
|
})
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type WretchInstance,
|
|
12
12
|
} from '@toad-contracts/frontend-http-client'
|
|
13
13
|
import wretch from 'wretch'
|
|
14
|
+
import { ApiError } from './errors'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The validated success-response body inferred from a route contract (every REST
|
|
@@ -75,7 +76,16 @@ export async function sendContract<T extends ApiContract>(
|
|
|
75
76
|
params: SendParams<T>,
|
|
76
77
|
): Promise<SuccessBodyOf<T>> {
|
|
77
78
|
const outcome = await sendByApiContract(client, contract, params)
|
|
78
|
-
if (outcome.error)
|
|
79
|
+
if (outcome.error) {
|
|
80
|
+
const error = outcome.error
|
|
81
|
+
// A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
|
|
82
|
+
// value (not an Error). Wrap it so call sites get `instanceof Error` + the server's
|
|
83
|
+
// message; anything already an Error (UnexpectedResponseError, request-validation
|
|
84
|
+
// SchemaValidationError, a network fault) is rethrown unchanged.
|
|
85
|
+
if (error instanceof Error) throw error
|
|
86
|
+
const { statusCode, body } = error as { statusCode: number; body: unknown }
|
|
87
|
+
throw new ApiError(statusCode, body)
|
|
88
|
+
}
|
|
79
89
|
return outcome.result!.body as SuccessBodyOf<T>
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ApiError, apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
|
|
3
|
+
|
|
4
|
+
// The contract client (`sendByApiContract`) reports a declared non-2xx as a plain
|
|
5
|
+
// `{ statusCode, headers, body }` value — body under `.body`, NOT an Error. Before the
|
|
6
|
+
// `ApiError` wrap, every `instanceof Error` check rendered "[object Object]" and every
|
|
7
|
+
// `.data.error` reader (parseConflict / parseCredentialError / login + probe messages)
|
|
8
|
+
// silently returned nothing. These tests lock that in.
|
|
9
|
+
|
|
10
|
+
describe('ApiError', () => {
|
|
11
|
+
const body = { error: { code: 'conflict', message: 'Nope', details: { reason: 'task_limit' } } }
|
|
12
|
+
|
|
13
|
+
it('is a real Error carrying the server message, status, and body', () => {
|
|
14
|
+
const e = new ApiError(409, body)
|
|
15
|
+
expect(e).toBeInstanceOf(Error)
|
|
16
|
+
expect(e.message).toBe('Nope') // not "[object Object]"
|
|
17
|
+
expect(e.statusCode).toBe(409)
|
|
18
|
+
expect(e.envelope).toEqual(body.error)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('falls back to a status message when the body carries no envelope', () => {
|
|
22
|
+
expect(new ApiError(500, 'gateway down').message).toBe('Request failed (HTTP 500)')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('apiErrorEnvelope', () => {
|
|
27
|
+
const envelope = { code: 'credential_required', message: 'Unlock', details: { vendor: 'claude' } }
|
|
28
|
+
|
|
29
|
+
it('reads the envelope from a wrapped ApiError (contract client)', () => {
|
|
30
|
+
expect(apiErrorEnvelope(new ApiError(428, { error: envelope }))).toEqual(envelope)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('reads the envelope from a bare { body } value (contract client, unwrapped)', () => {
|
|
34
|
+
expect(apiErrorEnvelope({ statusCode: 428, body: { error: envelope } })).toEqual(envelope)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('reads the envelope from a legacy $fetch FetchError (body under .data)', () => {
|
|
38
|
+
expect(apiErrorEnvelope({ statusCode: 428, data: { error: envelope } })).toEqual(envelope)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns undefined for a network/non-API error', () => {
|
|
42
|
+
expect(apiErrorEnvelope(new Error('socket hang up'))).toBeUndefined()
|
|
43
|
+
expect(apiErrorEnvelope(undefined)).toBeUndefined()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('apiErrorStatus', () => {
|
|
48
|
+
it('reads .statusCode (contract client) and .status (legacy)', () => {
|
|
49
|
+
expect(apiErrorStatus(new ApiError(503, {}))).toBe(503)
|
|
50
|
+
expect(apiErrorStatus({ status: 500 })).toBe(500)
|
|
51
|
+
expect(apiErrorStatus(new Error('x'))).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A failed API call, normalised to a real `Error`.
|
|
3
|
+
*
|
|
4
|
+
* The contract client (`sendByApiContract`) reports a contract-declared non-2xx as a
|
|
5
|
+
* plain `{ statusCode, headers, body }` value — NOT an `Error` — with the parsed
|
|
6
|
+
* `{ error: { code, message, details } }` envelope under `body`. Throwing that bare
|
|
7
|
+
* object breaks every `error instanceof Error` check (they fall to `String(error)` =
|
|
8
|
+
* `"[object Object]"`) and hides the server's message. `sendContract` wraps it in this
|
|
9
|
+
* class so call sites get `instanceof Error`, a real `.message` (the server's), the
|
|
10
|
+
* `.statusCode`, and the typed `.envelope`.
|
|
11
|
+
*/
|
|
12
|
+
export class ApiError extends Error {
|
|
13
|
+
readonly statusCode: number
|
|
14
|
+
/** The parsed response body (the `{ error: {...} }` envelope for our controllers). */
|
|
15
|
+
readonly body: unknown
|
|
16
|
+
|
|
17
|
+
constructor(statusCode: number, body: unknown) {
|
|
18
|
+
super(envelopeOf(body)?.message ?? `Request failed (HTTP ${statusCode})`)
|
|
19
|
+
this.name = 'ApiError'
|
|
20
|
+
this.statusCode = statusCode
|
|
21
|
+
this.body = body
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `{ code, message, details, issues }` envelope, when the body carries one. */
|
|
25
|
+
get envelope(): ApiErrorEnvelope | undefined {
|
|
26
|
+
return envelopeOf(this.body)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The error envelope every controller emits (`handleError` / contract request-validator). */
|
|
31
|
+
export interface ApiErrorEnvelope {
|
|
32
|
+
code?: string
|
|
33
|
+
message?: string
|
|
34
|
+
details?: unknown
|
|
35
|
+
issues?: { path?: string; message: string }[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the `{ error: {...} }` envelope out of a parsed response body, else undefined. */
|
|
39
|
+
function envelopeOf(body: unknown): ApiErrorEnvelope | undefined {
|
|
40
|
+
if (!body || typeof body !== 'object') return undefined
|
|
41
|
+
const error = (body as { error?: unknown }).error
|
|
42
|
+
return error && typeof error === 'object' ? (error as ApiErrorEnvelope) : undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pull the server error envelope out of any thrown API error, regardless of which client
|
|
47
|
+
* produced it: the contract client (`ApiError`, body under `.body`) or the legacy `$fetch`
|
|
48
|
+
* path (ofetch `FetchError`, body under `.data`). Returns undefined for network faults or
|
|
49
|
+
* non-API errors.
|
|
50
|
+
*/
|
|
51
|
+
export function apiErrorEnvelope(error: unknown): ApiErrorEnvelope | undefined {
|
|
52
|
+
if (error instanceof ApiError) return error.envelope
|
|
53
|
+
const e = error as { body?: unknown; data?: unknown }
|
|
54
|
+
return envelopeOf(e?.body) ?? envelopeOf(e?.data)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The HTTP status of a thrown API error, when present (contract client or `$fetch`). */
|
|
58
|
+
export function apiErrorStatus(error: unknown): number | undefined {
|
|
59
|
+
const e = error as { statusCode?: unknown; status?: unknown }
|
|
60
|
+
if (typeof e?.statusCode === 'number') return e.statusCode
|
|
61
|
+
if (typeof e?.status === 'number') return e.status
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* SAME guidance + "Configure AI" jump as the no-AI-provider startup banner.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { apiErrorEnvelope } from './api/errors'
|
|
10
|
+
|
|
9
11
|
/** The parsed shape of a backend conflict (`{ error: { code: 'conflict', details } }`). */
|
|
10
12
|
interface ConflictDetails {
|
|
11
13
|
reason?: string
|
|
@@ -13,15 +15,13 @@ interface ConflictDetails {
|
|
|
13
15
|
[key: string]: unknown
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
/** Pull a 409 conflict's `{ reason, message, details }` out of a thrown
|
|
18
|
+
/** Pull a 409 conflict's `{ reason, message, details }` out of a thrown API error, else null. */
|
|
17
19
|
export function parseConflict(
|
|
18
20
|
error: unknown,
|
|
19
21
|
): { reason?: string; message: string; details: ConflictDetails } | null {
|
|
20
|
-
const body = (
|
|
21
|
-
error as { data?: { error?: { code?: string; message?: string; details?: ConflictDetails } } }
|
|
22
|
-
)?.data?.error
|
|
22
|
+
const body = apiErrorEnvelope(error)
|
|
23
23
|
if (body?.code !== 'conflict') return null
|
|
24
|
-
const details = body.details ?? {}
|
|
24
|
+
const details = (body.details as ConflictDetails | undefined) ?? {}
|
|
25
25
|
return {
|
|
26
26
|
reason: typeof details.reason === 'string' ? details.reason : undefined,
|
|
27
27
|
message: body.message ?? 'This action conflicts with the current state.',
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type ComputedRef, type Ref, computed, ref } from 'vue'
|
|
2
|
+
import { apiErrorEnvelope, apiErrorStatus } from '~/composables/api/errors'
|
|
3
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The source-integration lifecycle shared by the document-source and task-source stores:
|
|
7
|
+
* the opt-in `available` gate, the per-source `connections` list, the `descriptorFor` /
|
|
8
|
+
* `connectionFor` / `isConnected` lookups, and a `probe()` that resolves all of it (hiding
|
|
9
|
+
* the UI when the integration is off). Both stores previously hand-rolled this, with
|
|
10
|
+
* inconsistent error handling — one captured the probe failure, the other swallowed it.
|
|
11
|
+
* Standardising here means every integration now records WHY a probe failed
|
|
12
|
+
* (`probeError`: a 503 "turned off on this deployment" vs a 500 "the backend errored"), so
|
|
13
|
+
* a settings panel can explain the empty state instead of a blanket "install it first".
|
|
14
|
+
*
|
|
15
|
+
* The store supplies only what differs: how to fetch its sources + connections, and the
|
|
16
|
+
* connect/disconnect calls (which feed `upsertConnection` / `removeConnection`). Source-
|
|
17
|
+
* specific extras (diagnostics, per-source enable toggles, plan/spawn) stay in the store.
|
|
18
|
+
*/
|
|
19
|
+
export function useSourceIntegration<
|
|
20
|
+
Source extends string,
|
|
21
|
+
Conn extends { source: Source },
|
|
22
|
+
Desc extends { source: Source },
|
|
23
|
+
>(opts: {
|
|
24
|
+
/** Fetch the configured sources + live connections; throws when the integration is off. */
|
|
25
|
+
fetch: () => Promise<{ sources: Desc[]; connections: Conn[] }>
|
|
26
|
+
/** Gate the probe (e.g. skip until a workspace is selected). */
|
|
27
|
+
enabled?: () => boolean
|
|
28
|
+
}): {
|
|
29
|
+
available: Ref<boolean | null>
|
|
30
|
+
probeError: Ref<{ status: number | null; message: string } | null>
|
|
31
|
+
sources: Ref<Desc[]>
|
|
32
|
+
connections: Ref<Conn[]>
|
|
33
|
+
connectedSources: ComputedRef<Desc[]>
|
|
34
|
+
anyConnected: ComputedRef<boolean>
|
|
35
|
+
descriptorFor: (source: Source) => Desc | undefined
|
|
36
|
+
connectionFor: (source: Source) => Conn | undefined
|
|
37
|
+
isConnected: (source: Source) => boolean
|
|
38
|
+
upsertConnection: (conn: Conn) => void
|
|
39
|
+
removeConnection: (source: Source) => void
|
|
40
|
+
probe: () => Promise<void>
|
|
41
|
+
} {
|
|
42
|
+
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
43
|
+
const available = ref<boolean | null>(null)
|
|
44
|
+
/** Why the last probe failed, when it did (kept rather than swallowed). */
|
|
45
|
+
const probeError = ref<{ status: number | null; message: string } | null>(null)
|
|
46
|
+
const sources = ref<Desc[]>([]) as Ref<Desc[]>
|
|
47
|
+
const { items: connections, upsert: upsertConnection } = useUpsertList<Conn>({
|
|
48
|
+
key: (c) => c.source,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const connectedSources = computed(() =>
|
|
52
|
+
sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
|
|
53
|
+
)
|
|
54
|
+
const anyConnected = computed(() => connections.value.length > 0)
|
|
55
|
+
|
|
56
|
+
function descriptorFor(source: Source): Desc | undefined {
|
|
57
|
+
return sources.value.find((s) => s.source === source)
|
|
58
|
+
}
|
|
59
|
+
function connectionFor(source: Source): Conn | undefined {
|
|
60
|
+
return connections.value.find((c) => c.source === source)
|
|
61
|
+
}
|
|
62
|
+
function isConnected(source: Source): boolean {
|
|
63
|
+
return connectionFor(source) !== undefined
|
|
64
|
+
}
|
|
65
|
+
function removeConnection(source: Source) {
|
|
66
|
+
connections.value = connections.value.filter((c) => c.source !== source)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Probe the integration: resolves `available`, the sources and connections. */
|
|
70
|
+
async function probe() {
|
|
71
|
+
if (opts.enabled && !opts.enabled()) return
|
|
72
|
+
try {
|
|
73
|
+
const { sources: srcs, connections: conns } = await opts.fetch()
|
|
74
|
+
available.value = true
|
|
75
|
+
probeError.value = null
|
|
76
|
+
sources.value = srcs
|
|
77
|
+
connections.value = conns
|
|
78
|
+
} catch (e) {
|
|
79
|
+
// 503 (integration disabled) or any error → hide the UI entry points, but keep the
|
|
80
|
+
// reason so a panel can explain it (503 = off here; 500 = the backend errored, e.g. an
|
|
81
|
+
// unapplied migration).
|
|
82
|
+
available.value = false
|
|
83
|
+
const serverMessage = apiErrorEnvelope(e)?.message
|
|
84
|
+
probeError.value = {
|
|
85
|
+
status: apiErrorStatus(e) ?? null,
|
|
86
|
+
message: serverMessage || (e instanceof Error ? e.message : String(e)),
|
|
87
|
+
}
|
|
88
|
+
sources.value = []
|
|
89
|
+
connections.value = []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
available,
|
|
95
|
+
probeError,
|
|
96
|
+
sources,
|
|
97
|
+
connections,
|
|
98
|
+
connectedSources,
|
|
99
|
+
anyConnected,
|
|
100
|
+
descriptorFor,
|
|
101
|
+
connectionFor,
|
|
102
|
+
isConnected,
|
|
103
|
+
upsertConnection,
|
|
104
|
+
removeConnection,
|
|
105
|
+
probe,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
3
|
+
|
|
4
|
+
interface Item {
|
|
5
|
+
id: string
|
|
6
|
+
v: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('useUpsertList', () => {
|
|
10
|
+
it('appends new items by default and replaces in place by key', () => {
|
|
11
|
+
const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id })
|
|
12
|
+
upsert({ id: 'a', v: 1 })
|
|
13
|
+
upsert({ id: 'b', v: 2 })
|
|
14
|
+
upsert({ id: 'a', v: 9 }) // replace, not duplicate
|
|
15
|
+
expect(items.value).toEqual([
|
|
16
|
+
{ id: 'a', v: 9 },
|
|
17
|
+
{ id: 'b', v: 2 },
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('prepends new items when prepend is set (newest-first)', () => {
|
|
22
|
+
const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, prepend: true })
|
|
23
|
+
upsert({ id: 'a', v: 1 })
|
|
24
|
+
upsert({ id: 'b', v: 2 })
|
|
25
|
+
expect(items.value.map((x) => x.id)).toEqual(['b', 'a'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('removes by key and looks up by key', () => {
|
|
29
|
+
const { items, upsert, remove, get } = useUpsertList<Item>({ key: (x) => x.id })
|
|
30
|
+
upsert({ id: 'a', v: 1 })
|
|
31
|
+
upsert({ id: 'b', v: 2 })
|
|
32
|
+
expect(get('b')).toEqual({ id: 'b', v: 2 })
|
|
33
|
+
remove('a')
|
|
34
|
+
expect(items.value.map((x) => x.id)).toEqual(['b'])
|
|
35
|
+
remove('missing') // no-op
|
|
36
|
+
expect(items.value).toHaveLength(1)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('supports composite keys and hydrate-from-snapshot', () => {
|
|
40
|
+
interface Doc {
|
|
41
|
+
source: string
|
|
42
|
+
externalId: string
|
|
43
|
+
}
|
|
44
|
+
const { items, upsert, hydrate } = useUpsertList<Doc>({
|
|
45
|
+
key: (d) => `${d.source}:${d.externalId}`,
|
|
46
|
+
})
|
|
47
|
+
hydrate([{ source: 'jira', externalId: '1' }])
|
|
48
|
+
upsert({ source: 'jira', externalId: '1' }) // same composite key → replace
|
|
49
|
+
upsert({ source: 'gh', externalId: '1' }) // different source → new
|
|
50
|
+
expect(items.value).toHaveLength(2)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('seeds from initial without aliasing the caller array', () => {
|
|
54
|
+
const seed: Item[] = [{ id: 'a', v: 1 }]
|
|
55
|
+
const { items, upsert } = useUpsertList<Item>({ key: (x) => x.id, initial: seed })
|
|
56
|
+
upsert({ id: 'b', v: 2 })
|
|
57
|
+
expect(items.value).toHaveLength(2)
|
|
58
|
+
expect(seed).toHaveLength(1) // original untouched
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type Ref, ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A keyed list with find-by-key upsert — the pattern reimplemented in ~13 stores
|
|
5
|
+
* (`const i = list.findIndex((x) => x.id === item.id); if (i >= 0) list[i] = item else …`).
|
|
6
|
+
* Wraps a reactive `T[]` and exposes `upsert` (replace-in-place or insert), `remove`,
|
|
7
|
+
* `get`, and `hydrate` (replace from a server snapshot), all keyed by a caller-supplied
|
|
8
|
+
* `key` function. New items append by default, or `prepend: true` for newest-first inboxes.
|
|
9
|
+
*
|
|
10
|
+
* The returned `items` ref stays directly assignable, so a store can expose it under a
|
|
11
|
+
* domain name (`const { items: documents, upsert } = useUpsertList(...)`) and callers /
|
|
12
|
+
* tests can still do `store.documents = [...]`.
|
|
13
|
+
*/
|
|
14
|
+
export function useUpsertList<T>(opts: {
|
|
15
|
+
/** Stable identity for an item (e.g. `(x) => x.id`, or `(x) => `${x.source}:${x.externalId}``). */
|
|
16
|
+
key: (item: T) => unknown
|
|
17
|
+
/** Insert position for a brand-new item: `true` ⇒ unshift (newest-first), else push. */
|
|
18
|
+
prepend?: boolean
|
|
19
|
+
/** Seed contents (copied, not aliased). */
|
|
20
|
+
initial?: T[]
|
|
21
|
+
}): {
|
|
22
|
+
items: Ref<T[]>
|
|
23
|
+
upsert: (item: T) => void
|
|
24
|
+
remove: (keyValue: unknown) => void
|
|
25
|
+
get: (keyValue: unknown) => T | undefined
|
|
26
|
+
hydrate: (next: T[]) => void
|
|
27
|
+
indexOf: (keyValue: unknown) => number
|
|
28
|
+
} {
|
|
29
|
+
const items = ref<T[]>(opts.initial ? [...opts.initial] : []) as Ref<T[]>
|
|
30
|
+
|
|
31
|
+
function indexOf(keyValue: unknown): number {
|
|
32
|
+
return items.value.findIndex((x) => opts.key(x) === keyValue)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function upsert(item: T) {
|
|
36
|
+
const i = indexOf(opts.key(item))
|
|
37
|
+
if (i >= 0) items.value[i] = item
|
|
38
|
+
else if (opts.prepend) items.value.unshift(item)
|
|
39
|
+
else items.value.push(item)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function remove(keyValue: unknown) {
|
|
43
|
+
const i = indexOf(keyValue)
|
|
44
|
+
if (i >= 0) items.value.splice(i, 1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function get(keyValue: unknown): T | undefined {
|
|
48
|
+
const i = indexOf(keyValue)
|
|
49
|
+
return i >= 0 ? items.value[i] : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hydrate(next: T[]) {
|
|
53
|
+
items.value = [...next]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { items, upsert, remove, get, hydrate, indexOf }
|
|
57
|
+
}
|
package/app/stores/documents.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import {
|
|
2
|
+
import { ref } from 'vue'
|
|
3
3
|
import type {
|
|
4
4
|
DocumentBoardPlan,
|
|
5
5
|
DocumentConnection,
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
DocumentSourceKind,
|
|
9
9
|
SourceDocument,
|
|
10
10
|
} from '~/types/domain'
|
|
11
|
+
import { useSourceIntegration } from '~/composables/useSourceIntegration'
|
|
12
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
11
13
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -24,83 +26,46 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
24
26
|
const api = useApi()
|
|
25
27
|
const workspace = useWorkspaceStore()
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`).
|
|
30
|
+
const integration = useSourceIntegration<
|
|
31
|
+
DocumentSourceKind,
|
|
32
|
+
DocumentConnection,
|
|
33
|
+
DocumentSourceDescriptor
|
|
34
|
+
>({
|
|
35
|
+
enabled: () => !!workspace.workspaceId,
|
|
36
|
+
fetch: async () => {
|
|
37
|
+
const [{ sources }, { connections }] = await Promise.all([
|
|
38
|
+
api.listDocumentSources(workspace.requireId()),
|
|
39
|
+
api.listDocumentConnections(workspace.requireId()),
|
|
40
|
+
])
|
|
41
|
+
return { sources, connections }
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
const { available, sources, connections, connectedSources, anyConnected } = integration
|
|
45
|
+
const { descriptorFor, connectionFor, isConnected, probe } = integration
|
|
46
|
+
|
|
47
|
+
const { items: documents, upsert: upsertDoc } = useUpsertList<SourceDocument>({
|
|
48
|
+
key: (d) => `${d.source}:${d.externalId}`,
|
|
49
|
+
prepend: true,
|
|
50
|
+
})
|
|
34
51
|
const loading = ref(false)
|
|
35
52
|
|
|
36
|
-
/** Sources the workspace currently has a live connection to. */
|
|
37
|
-
const connectedSources = computed(() =>
|
|
38
|
-
sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
|
|
39
|
-
)
|
|
40
|
-
const anyConnected = computed(() => connections.value.length > 0)
|
|
41
|
-
|
|
42
|
-
function descriptorFor(source: DocumentSourceKind): DocumentSourceDescriptor | undefined {
|
|
43
|
-
return sources.value.find((s) => s.source === source)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function connectionFor(source: DocumentSourceKind): DocumentConnection | undefined {
|
|
47
|
-
return connections.value.find((c) => c.source === source)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function isConnected(source: DocumentSourceKind): boolean {
|
|
51
|
-
return connectionFor(source) !== undefined
|
|
52
|
-
}
|
|
53
|
-
|
|
54
53
|
/** Imported documents currently attached to a given block. */
|
|
55
54
|
function docsForBlock(blockId: string): SourceDocument[] {
|
|
56
55
|
return documents.value.filter((d) => d.linkedBlockId === blockId)
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
/** Merge a document returned by the backend into the local cache. */
|
|
60
|
-
function upsertDoc(doc: SourceDocument) {
|
|
61
|
-
const i = documents.value.findIndex(
|
|
62
|
-
(d) => d.source === doc.source && d.externalId === doc.externalId,
|
|
63
|
-
)
|
|
64
|
-
if (i >= 0) documents.value[i] = doc
|
|
65
|
-
else documents.value.unshift(doc)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function upsertConnection(conn: DocumentConnection) {
|
|
69
|
-
const i = connections.value.findIndex((c) => c.source === conn.source)
|
|
70
|
-
if (i >= 0) connections.value[i] = conn
|
|
71
|
-
else connections.value.push(conn)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Probe the integration: resolves `available`, the sources and connections. */
|
|
75
|
-
async function probe() {
|
|
76
|
-
if (!workspace.workspaceId) return
|
|
77
|
-
try {
|
|
78
|
-
const [{ sources: srcs }, { connections: conns }] = await Promise.all([
|
|
79
|
-
api.listDocumentSources(workspace.requireId()),
|
|
80
|
-
api.listDocumentConnections(workspace.requireId()),
|
|
81
|
-
])
|
|
82
|
-
available.value = true
|
|
83
|
-
sources.value = srcs
|
|
84
|
-
connections.value = conns
|
|
85
|
-
} catch {
|
|
86
|
-
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
87
|
-
available.value = false
|
|
88
|
-
sources.value = []
|
|
89
|
-
connections.value = []
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
58
|
/** Connect the workspace to a source with its credential bag. */
|
|
94
59
|
async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
|
|
95
60
|
const conn = await api.connectDocumentSource(workspace.requireId(), source, credentials)
|
|
96
|
-
upsertConnection(conn)
|
|
61
|
+
integration.upsertConnection(conn)
|
|
97
62
|
available.value = true
|
|
98
63
|
}
|
|
99
64
|
|
|
100
65
|
/** Disconnect the workspace from a source. */
|
|
101
66
|
async function disconnect(source: DocumentSourceKind) {
|
|
102
67
|
await api.disconnectDocumentSource(workspace.requireId(), source)
|
|
103
|
-
|
|
68
|
+
integration.removeConnection(source)
|
|
104
69
|
}
|
|
105
70
|
|
|
106
71
|
/** Load the imported documents for the workspace (across sources). */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
|
-
import { computed
|
|
2
|
+
import { computed } from 'vue'
|
|
3
3
|
import type { Notification } from '~/types/domain'
|
|
4
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
4
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -14,7 +15,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
14
15
|
const api = useApi()
|
|
15
16
|
|
|
16
17
|
/** All open notifications, newest-first. */
|
|
17
|
-
const
|
|
18
|
+
const {
|
|
19
|
+
items: open,
|
|
20
|
+
upsert: upsertOpen,
|
|
21
|
+
remove,
|
|
22
|
+
} = useUpsertList<Notification>({ key: (n) => n.id, prepend: true })
|
|
18
23
|
|
|
19
24
|
/** Replace the cache from a server snapshot. */
|
|
20
25
|
function hydrate(notifications: Notification[]) {
|
|
@@ -28,13 +33,11 @@ export const useNotificationsStore = defineStore('notifications', () => {
|
|
|
28
33
|
* replaced in place; a resolved one (acted/dismissed) is removed from the inbox.
|
|
29
34
|
*/
|
|
30
35
|
function upsert(notification: Notification) {
|
|
31
|
-
const i = open.value.findIndex((n) => n.id === notification.id)
|
|
32
36
|
if (notification.status !== 'open') {
|
|
33
|
-
|
|
37
|
+
remove(notification.id)
|
|
34
38
|
return
|
|
35
39
|
}
|
|
36
|
-
|
|
37
|
-
else open.value.unshift(notification)
|
|
40
|
+
upsertOpen(notification)
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/** Open notifications for a given block (for the board card badge). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
3
4
|
import type {
|
|
4
5
|
PersonalSubscriptionStatus,
|
|
5
6
|
StorePersonalSubscriptionInput,
|
|
@@ -45,7 +46,7 @@ export interface PendingCredential {
|
|
|
45
46
|
export function parseCredentialError(
|
|
46
47
|
error: unknown,
|
|
47
48
|
): { vendor: SubscriptionVendor; reason: PendingCredential['reason'] } | null {
|
|
48
|
-
const data = (error
|
|
49
|
+
const data = apiErrorEnvelope(error)
|
|
49
50
|
if (data?.code !== 'credential_required') return null
|
|
50
51
|
const details = data.details as {
|
|
51
52
|
vendor?: SubscriptionVendor
|
package/app/stores/tasks.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
TaskSourceKind,
|
|
9
9
|
TaskSourceState,
|
|
10
10
|
} from '~/types/domain'
|
|
11
|
+
import { useSourceIntegration } from '~/composables/useSourceIntegration'
|
|
12
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
11
13
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
12
14
|
import { useBoardStore } from '~/stores/board'
|
|
13
15
|
|
|
@@ -27,95 +29,42 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
27
29
|
const api = useApi()
|
|
28
30
|
const workspace = useWorkspaceStore()
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
// Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`). Its
|
|
33
|
+
// `probeError` is what lets the settings panel explain *why* nothing is surfaced
|
|
34
|
+
// (integration disabled vs a server/backend error) instead of "install it first".
|
|
35
|
+
const integration = useSourceIntegration<TaskSourceKind, TaskConnection, TaskSourceState>({
|
|
36
|
+
enabled: () => !!workspace.workspaceId,
|
|
37
|
+
fetch: async () => {
|
|
38
|
+
const [{ sources }, { connections }] = await Promise.all([
|
|
39
|
+
api.listTaskSources(workspace.requireId()),
|
|
40
|
+
api.listTaskConnections(workspace.requireId()),
|
|
41
|
+
])
|
|
42
|
+
return { sources, connections }
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
const { available, probeError, sources, connections, connectedSources, anyConnected } =
|
|
46
|
+
integration
|
|
47
|
+
const { descriptorFor, connectionFor, isConnected, probe } = integration
|
|
48
|
+
|
|
49
|
+
const { items: tasks, upsert: upsertTask } = useUpsertList<SourceTask>({
|
|
50
|
+
key: (t) => `${t.source}:${t.externalId}`,
|
|
51
|
+
prepend: true,
|
|
52
|
+
})
|
|
43
53
|
/** The last live setup-check verdict per source (from `checkSetup`). */
|
|
44
54
|
const diagnostics = ref<Partial<Record<TaskSourceKind, TaskSourceDiagnostic>>>({})
|
|
45
55
|
/** The source currently running a setup check, if any. */
|
|
46
56
|
const checking = ref<TaskSourceKind | null>(null)
|
|
47
57
|
const loading = ref(false)
|
|
48
58
|
|
|
49
|
-
/** Sources the workspace currently has a live connection to. */
|
|
50
|
-
const connectedSources = computed(() =>
|
|
51
|
-
sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
|
|
52
|
-
)
|
|
53
|
-
const anyConnected = computed(() => connections.value.length > 0)
|
|
54
|
-
|
|
55
59
|
/** Sources offered for import: available (connected / App installed) AND enabled. */
|
|
56
60
|
const offeredSources = computed(() => sources.value.filter((s) => s.available && s.enabled))
|
|
57
61
|
const anyOffered = computed(() => offeredSources.value.length > 0)
|
|
58
62
|
|
|
59
|
-
function descriptorFor(source: TaskSourceKind): TaskSourceState | undefined {
|
|
60
|
-
return sources.value.find((s) => s.source === source)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function connectionFor(source: TaskSourceKind): TaskConnection | undefined {
|
|
64
|
-
return connections.value.find((c) => c.source === source)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function isConnected(source: TaskSourceKind): boolean {
|
|
68
|
-
return connectionFor(source) !== undefined
|
|
69
|
-
}
|
|
70
|
-
|
|
71
63
|
/** Imported issues currently attached to a given block. */
|
|
72
64
|
function tasksForBlock(blockId: string): SourceTask[] {
|
|
73
65
|
return tasks.value.filter((t) => t.linkedBlockId === blockId)
|
|
74
66
|
}
|
|
75
67
|
|
|
76
|
-
/** Merge an issue returned by the backend into the local cache. */
|
|
77
|
-
function upsertTask(task: SourceTask) {
|
|
78
|
-
const i = tasks.value.findIndex(
|
|
79
|
-
(t) => t.source === task.source && t.externalId === task.externalId,
|
|
80
|
-
)
|
|
81
|
-
if (i >= 0) tasks.value[i] = task
|
|
82
|
-
else tasks.value.unshift(task)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function upsertConnection(conn: TaskConnection) {
|
|
86
|
-
const i = connections.value.findIndex((c) => c.source === conn.source)
|
|
87
|
-
if (i >= 0) connections.value[i] = conn
|
|
88
|
-
else connections.value.push(conn)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Probe the integration: resolves `available`, the sources and connections. */
|
|
92
|
-
async function probe() {
|
|
93
|
-
if (!workspace.workspaceId) return
|
|
94
|
-
try {
|
|
95
|
-
const [{ sources: srcs }, { connections: conns }] = await Promise.all([
|
|
96
|
-
api.listTaskSources(workspace.requireId()),
|
|
97
|
-
api.listTaskConnections(workspace.requireId()),
|
|
98
|
-
])
|
|
99
|
-
available.value = true
|
|
100
|
-
probeError.value = null
|
|
101
|
-
sources.value = srcs
|
|
102
|
-
connections.value = conns
|
|
103
|
-
} catch (e) {
|
|
104
|
-
// 503 (integration disabled) or any error → hide the UI entry points, but keep
|
|
105
|
-
// the reason so the settings panel can explain it (a 503 is "turned off on this
|
|
106
|
-
// deployment"; a 500 is "the backend errored — e.g. a migration isn't applied").
|
|
107
|
-
available.value = false
|
|
108
|
-
const err = e as { statusCode?: number; data?: { error?: { message?: string } } }
|
|
109
|
-
const serverMessage = err?.data?.error?.message
|
|
110
|
-
probeError.value = {
|
|
111
|
-
status: err?.statusCode ?? null,
|
|
112
|
-
message: serverMessage || (e instanceof Error ? e.message : String(e)),
|
|
113
|
-
}
|
|
114
|
-
sources.value = []
|
|
115
|
-
connections.value = []
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
68
|
/**
|
|
120
69
|
* Run a live setup check for a source (authenticate + read), caching the verdict
|
|
121
70
|
* so the panel can show exactly what's wrong (missing App / wrong token / lacking
|
|
@@ -137,14 +86,14 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
137
86
|
/** Connect the workspace to a source with its credential bag. */
|
|
138
87
|
async function connect(source: TaskSourceKind, credentials: Record<string, string>) {
|
|
139
88
|
const conn = await api.connectTaskSource(workspace.requireId(), source, credentials)
|
|
140
|
-
upsertConnection(conn)
|
|
89
|
+
integration.upsertConnection(conn)
|
|
141
90
|
available.value = true
|
|
142
91
|
}
|
|
143
92
|
|
|
144
93
|
/** Disconnect the workspace from a source. */
|
|
145
94
|
async function disconnect(source: TaskSourceKind) {
|
|
146
95
|
await api.disconnectTaskSource(workspace.requireId(), source)
|
|
147
|
-
|
|
96
|
+
integration.removeConnection(source)
|
|
148
97
|
}
|
|
149
98
|
|
|
150
99
|
/** Enable or disable a source for the workspace (the per-workspace toggle). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
33
33
|
"vue": "^3.5.38",
|
|
34
34
|
"wretch": "^3.0.9",
|
|
35
|
-
"@cat-factory/contracts": "0.
|
|
35
|
+
"@cat-factory/contracts": "0.36.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@toad-contracts/testing": "0.3.1",
|