@cat-factory/app 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- package/package.json +45 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ref, reactive, computed, nextTick } from 'vue'
|
|
2
|
+
import { parseOutputOutline } from '~/utils/agentOutput'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The prose reader for an agent step's markdown output: its heading outline, the
|
|
6
|
+
* per-section collapse state, and the scroll-spy that keeps the ToC in sync.
|
|
7
|
+
* Owns the scroll container + per-section element refs the template binds; the
|
|
8
|
+
* details card is always the first anchor. `reset()` re-seeds (all sections
|
|
9
|
+
* expanded, scrolled to top) whenever a different step opens.
|
|
10
|
+
*/
|
|
11
|
+
export function useStepProse(getOutput: () => string) {
|
|
12
|
+
const outline = computed(() => parseOutputOutline(getOutput()))
|
|
13
|
+
const tocSections = computed(() => outline.value.sections.filter((s) => s.depth > 0))
|
|
14
|
+
const hasOutput = computed(() => !!getOutput().trim())
|
|
15
|
+
|
|
16
|
+
const collapsed = reactive<Record<string, boolean>>({})
|
|
17
|
+
const activeId = ref<string>('step-details')
|
|
18
|
+
const scrollEl = ref<HTMLElement | null>(null)
|
|
19
|
+
const sectionEls = reactive<Record<string, HTMLElement | null>>({})
|
|
20
|
+
|
|
21
|
+
// Anchors the ToC navigates + the scroll-spy tracks: the details card first, then
|
|
22
|
+
// every heading section of the prose.
|
|
23
|
+
const anchors = computed(() => ['step-details', ...tocSections.value.map((s) => s.id)])
|
|
24
|
+
|
|
25
|
+
function toggle(id: string) {
|
|
26
|
+
collapsed[id] = !collapsed[id]
|
|
27
|
+
}
|
|
28
|
+
function setAll(value: boolean) {
|
|
29
|
+
for (const s of outline.value.sections) collapsed[s.id] = value
|
|
30
|
+
}
|
|
31
|
+
const allCollapsed = computed(
|
|
32
|
+
() => outline.value.sections.length > 0 && outline.value.sections.every((s) => collapsed[s.id]),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
async function goTo(id: string) {
|
|
36
|
+
if (collapsed[id]) collapsed[id] = false
|
|
37
|
+
activeId.value = id
|
|
38
|
+
await nextTick()
|
|
39
|
+
sectionEls[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function onScroll() {
|
|
43
|
+
const container = scrollEl.value
|
|
44
|
+
if (!container) return
|
|
45
|
+
const line = container.getBoundingClientRect().top + 80
|
|
46
|
+
let current = anchors.value[0] ?? 'step-details'
|
|
47
|
+
for (const id of anchors.value) {
|
|
48
|
+
const el = sectionEls[id]
|
|
49
|
+
if (el && el.getBoundingClientRect().top <= line) current = id
|
|
50
|
+
else break
|
|
51
|
+
}
|
|
52
|
+
activeId.value = current
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Re-seed (all sections expanded, scrolled to top) for a freshly-opened step.
|
|
56
|
+
function reset() {
|
|
57
|
+
for (const k of Object.keys(collapsed)) delete collapsed[k]
|
|
58
|
+
activeId.value = 'step-details'
|
|
59
|
+
void nextTick(() => scrollEl.value?.scrollTo({ top: 0 }))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
outline,
|
|
64
|
+
tocSections,
|
|
65
|
+
hasOutput,
|
|
66
|
+
collapsed,
|
|
67
|
+
activeId,
|
|
68
|
+
scrollEl,
|
|
69
|
+
sectionEls,
|
|
70
|
+
anchors,
|
|
71
|
+
toggle,
|
|
72
|
+
setAll,
|
|
73
|
+
allCollapsed,
|
|
74
|
+
goTo,
|
|
75
|
+
onScroll,
|
|
76
|
+
reset,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live elapsed-time clock for a single pipeline step. A 1s tick drives the
|
|
6
|
+
* counting-up duration while the step is actively running; the clock freezes at
|
|
7
|
+
* the step's finish, the run's failure time, or the moment it parked on a human
|
|
8
|
+
* (`pausedAt`) so a mid-flight step (no `finishedAt`) doesn't tick up forever.
|
|
9
|
+
*/
|
|
10
|
+
export function useStepTimer(opts: {
|
|
11
|
+
step: () => PipelineStep | null
|
|
12
|
+
runFailed: () => boolean
|
|
13
|
+
failureAt: () => number | null | undefined
|
|
14
|
+
}) {
|
|
15
|
+
// A 1s tick so a still-running step's elapsed time counts up live while open.
|
|
16
|
+
const nowTick = ref(0)
|
|
17
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
18
|
+
onMounted(() => {
|
|
19
|
+
nowTick.value = Date.now()
|
|
20
|
+
timer = setInterval(() => (nowTick.value = Date.now()), 1000)
|
|
21
|
+
})
|
|
22
|
+
onUnmounted(() => {
|
|
23
|
+
if (timer) clearInterval(timer)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// A step that is finished, failed, or parked on a human is not actively
|
|
27
|
+
// executing — no ticking clock or spinner. `pausedAt` is the "waiting on input"
|
|
28
|
+
// freeze.
|
|
29
|
+
const isRunning = computed(() => {
|
|
30
|
+
const s = opts.step()
|
|
31
|
+
return !!s?.startedAt && !s?.finishedAt && s?.pausedAt == null && !opts.runFailed()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/** Elapsed/total execution time in ms — null until the step has started. */
|
|
35
|
+
const durationMs = computed(() => {
|
|
36
|
+
const s = opts.step()
|
|
37
|
+
if (s?.startedAt == null) return null
|
|
38
|
+
// Freeze the clock once the step stops working: at its finish, else at the
|
|
39
|
+
// failure time once the run has failed, else at the moment it parked on a
|
|
40
|
+
// human (`pausedAt`). Otherwise it is live, so count up to the current tick.
|
|
41
|
+
const end =
|
|
42
|
+
s.finishedAt ??
|
|
43
|
+
(opts.runFailed() ? (opts.failureAt() ?? s.startedAt) : (s.pausedAt ?? nowTick.value))
|
|
44
|
+
return Math.max(0, end - s.startedAt)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const durationLabel = computed(() =>
|
|
48
|
+
durationMs.value == null ? null : formatDuration(durationMs.value),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return { isRunning, durationMs, durationLabel }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatDuration(ms: number): string {
|
|
55
|
+
const totalSec = Math.round(ms / 1000)
|
|
56
|
+
if (totalSec < 60) return `${totalSec}s`
|
|
57
|
+
const m = Math.floor(totalSec / 60)
|
|
58
|
+
const sec = totalSec % 60
|
|
59
|
+
if (m < 60) return sec ? `${m}m ${sec}s` : `${m}m`
|
|
60
|
+
const h = Math.floor(m / 60)
|
|
61
|
+
const min = m % 60
|
|
62
|
+
return min ? `${h}h ${min}m` : `${h}h`
|
|
63
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import { onMounted, onBeforeUnmount } from 'vue'
|
|
3
|
+
import { useRafFn } from '@vueuse/core'
|
|
4
|
+
import { lodAtLeast } from '~/composables/useSemanticZoom'
|
|
5
|
+
|
|
6
|
+
type Rect = { left: number; right: number; top: number; bottom: number }
|
|
7
|
+
|
|
8
|
+
function intersects(a: Rect, b: Rect) {
|
|
9
|
+
return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sameSet(a: Set<string>, b: Set<string>) {
|
|
13
|
+
if (a.size !== b.size) return false
|
|
14
|
+
for (const id of a) if (!b.has(id)) return false
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Board-level driver deciding which task cards may expand their full pipeline list
|
|
20
|
+
* once zoomed in (the deep `steps`/`subtasks` bands). Two gates, recomputed every
|
|
21
|
+
* frame against live DOM rects so they follow pan / zoom / drag / resize:
|
|
22
|
+
*
|
|
23
|
+
* - visibility: a task expands only while its card overlaps the board viewport.
|
|
24
|
+
* - overlap: walking the visible candidates nearest-to-screen-centre first, a task
|
|
25
|
+
* expands only if its footprint doesn't collide with one already granted, so the
|
|
26
|
+
* centre-most task wins an overlap and the rest stay compact.
|
|
27
|
+
*
|
|
28
|
+
* Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
|
|
29
|
+
* Only tasks with a running pipeline (steps to show) are candidates — a task that
|
|
30
|
+
* wouldn't expand never blocks a neighbour.
|
|
31
|
+
*/
|
|
32
|
+
export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
33
|
+
const board = useBoardStore()
|
|
34
|
+
const execution = useExecutionStore()
|
|
35
|
+
const ui = useUiStore()
|
|
36
|
+
const store = useTaskExpansionStore()
|
|
37
|
+
|
|
38
|
+
function rectOf(id: string): DOMRect | null {
|
|
39
|
+
const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
|
|
40
|
+
return el ? el.getBoundingClientRect() : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recompute() {
|
|
44
|
+
// Task cards only expand at the deep zoom bands; clear everything otherwise.
|
|
45
|
+
if (!lodAtLeast(ui.lod, 'steps')) {
|
|
46
|
+
if (store.allowed.size) store.setAllowed(new Set())
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const view = container.value?.getBoundingClientRect()
|
|
50
|
+
if (!view) return
|
|
51
|
+
const cx = view.left + view.width / 2
|
|
52
|
+
const cy = view.top + view.height / 2
|
|
53
|
+
|
|
54
|
+
const candidates: { id: string; rect: DOMRect; dist: number }[] = []
|
|
55
|
+
for (const t of board.allTasks) {
|
|
56
|
+
// Only tasks whose run actually has steps would expand a pipeline list.
|
|
57
|
+
if (!execution.getByBlock(t.id)?.steps.length) continue
|
|
58
|
+
const rect = rectOf(t.id)
|
|
59
|
+
if (!rect) continue
|
|
60
|
+
// Visibility: the card must intersect the board viewport.
|
|
61
|
+
if (!intersects(rect, view)) continue
|
|
62
|
+
// Stable anchor: the card's top-centre. It doesn't move as the card grows
|
|
63
|
+
// downward, so the ordering can't oscillate as cards expand / collapse.
|
|
64
|
+
const ax = rect.left + rect.width / 2
|
|
65
|
+
const ay = rect.top
|
|
66
|
+
const dist = (ax - cx) ** 2 + (ay - cy) ** 2
|
|
67
|
+
candidates.push({ id: t.id, rect, dist })
|
|
68
|
+
}
|
|
69
|
+
candidates.sort((a, b) => a.dist - b.dist)
|
|
70
|
+
|
|
71
|
+
// Greedy by distance to centre: a candidate is granted only if its rect clears
|
|
72
|
+
// every rect already granted, so the centre-most card wins any overlap.
|
|
73
|
+
const claimed: DOMRect[] = []
|
|
74
|
+
const next = new Set<string>()
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
if (claimed.some((r) => intersects(c.rect, r))) continue
|
|
77
|
+
next.add(c.id)
|
|
78
|
+
claimed.push(c.rect)
|
|
79
|
+
}
|
|
80
|
+
if (!sameSet(next, store.allowed)) store.setAllowed(next)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { pause, resume } = useRafFn(recompute, { immediate: false })
|
|
84
|
+
onMounted(() => {
|
|
85
|
+
store.setDriverActive(true)
|
|
86
|
+
resume()
|
|
87
|
+
})
|
|
88
|
+
onBeforeUnmount(() => {
|
|
89
|
+
pause()
|
|
90
|
+
store.setDriverActive(false)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ref, onScopeDispose } from 'vue'
|
|
2
|
+
import type { WorkspaceEvent } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Subscribes to the backend's per-workspace WebSocket event stream and keeps the
|
|
6
|
+
* board in sync in real time — the replacement for the old polling clock. Mount
|
|
7
|
+
* once (e.g. on the board page) after the workspace is ready.
|
|
8
|
+
*
|
|
9
|
+
* `execution` events patch the run + its block directly; `bootstrap` events patch
|
|
10
|
+
* a repo-bootstrap run + its service frame (live "bootstrapping…" progress); the
|
|
11
|
+
* coarse `board` event (module materialised, run cancelled) triggers a debounced
|
|
12
|
+
* full refresh. On every (re)connect we refresh once to reconcile anything missed
|
|
13
|
+
* while disconnected, so the server stays the source of truth and a dropped socket
|
|
14
|
+
* self-heals.
|
|
15
|
+
*/
|
|
16
|
+
export function useWorkspaceStream() {
|
|
17
|
+
const workspace = useWorkspaceStore()
|
|
18
|
+
const execution = useExecutionStore()
|
|
19
|
+
const board = useBoardStore()
|
|
20
|
+
const agentRuns = useAgentRunsStore()
|
|
21
|
+
const notifications = useNotificationsStore()
|
|
22
|
+
const observability = useObservabilityStore()
|
|
23
|
+
const requirements = useRequirementsStore()
|
|
24
|
+
const consensus = useConsensusStore()
|
|
25
|
+
const clarity = useClarityStore()
|
|
26
|
+
const api = useApi()
|
|
27
|
+
const apiBase = useRuntimeConfig().public.apiBase
|
|
28
|
+
|
|
29
|
+
const connected = ref(false)
|
|
30
|
+
|
|
31
|
+
let socket: WebSocket | null = null
|
|
32
|
+
let stopped = false
|
|
33
|
+
let attempt = 0
|
|
34
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
35
|
+
let boardDebounce: ReturnType<typeof setTimeout> | null = null
|
|
36
|
+
|
|
37
|
+
// http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
|
|
38
|
+
const wsBase = String(apiBase).replace(/^http/, 'ws')
|
|
39
|
+
|
|
40
|
+
function debouncedBoardRefresh() {
|
|
41
|
+
if (boardDebounce) clearTimeout(boardDebounce)
|
|
42
|
+
boardDebounce = setTimeout(() => void workspace.refresh(), 300)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onMessage(raw: string) {
|
|
46
|
+
let event: WorkspaceEvent
|
|
47
|
+
try {
|
|
48
|
+
event = JSON.parse(raw) as WorkspaceEvent
|
|
49
|
+
} catch {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (event.type === 'execution') {
|
|
53
|
+
// Full instance drives the step-level UI; agentRuns derives its coarse
|
|
54
|
+
// failure/retry summary from the same store, so no extra call is needed.
|
|
55
|
+
execution.upsert(event.instance)
|
|
56
|
+
if (event.block) board.upsert(event.block)
|
|
57
|
+
} else if (event.type === 'board') {
|
|
58
|
+
debouncedBoardRefresh()
|
|
59
|
+
} else if (event.type === 'bootstrap') {
|
|
60
|
+
// Patch the run's live status/subtasks and its provisional/linked frame so
|
|
61
|
+
// the "bootstrapping…" card updates in place (then flips to a ready service
|
|
62
|
+
// or a failed badge) without a full refresh.
|
|
63
|
+
agentRuns.upsertBootstrap(event.job)
|
|
64
|
+
if (event.block) board.upsert(event.block)
|
|
65
|
+
} else if (event.type === 'notification') {
|
|
66
|
+
// A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
|
|
67
|
+
// inbox + per-block badge in place (resolved ones drop out of the inbox).
|
|
68
|
+
notifications.upsert(event.notification)
|
|
69
|
+
} else if (event.type === 'llmCall') {
|
|
70
|
+
// A container agent just made a model call — fold the compact summary into the
|
|
71
|
+
// observability store so an open "Model activity" panel updates live (and keeps
|
|
72
|
+
// updating even when the durable driver is evicted: the proxy emits these
|
|
73
|
+
// independently of the run's poll loop).
|
|
74
|
+
observability.appendCall(event.call)
|
|
75
|
+
} else if (event.type === 'requirements') {
|
|
76
|
+
// The async incorporate + re-review cycle changed a review's status — patch the cache
|
|
77
|
+
// so an open review window / inspector reflects it live ("incorporating…" → the next
|
|
78
|
+
// cycle / converged). The summons back, when needed, arrives as a `notification`.
|
|
79
|
+
requirements.upsert(event.review)
|
|
80
|
+
} else if (event.type === 'consensus') {
|
|
81
|
+
// A consensus session advanced (a round landed, the synthesis completed, or it
|
|
82
|
+
// failed) — patch the cache so an open Consensus Session window renders the
|
|
83
|
+
// multi-model process live, round by round.
|
|
84
|
+
consensus.upsert(event.session)
|
|
85
|
+
} else if (event.type === 'clarity') {
|
|
86
|
+
// The async incorporate + re-review cycle changed a clarity review's status — patch the
|
|
87
|
+
// cache so an open review window / inspector reflects it live ("incorporating…" → the
|
|
88
|
+
// next cycle / converged). The summons back, when needed, arrives as a `notification`.
|
|
89
|
+
clarity.upsert(event.review)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function connect() {
|
|
94
|
+
if (stopped || !workspace.workspaceId) return
|
|
95
|
+
const workspaceId = workspace.workspaceId
|
|
96
|
+
|
|
97
|
+
// A browser can't set Authorization on a WS handshake, so mint a short-lived,
|
|
98
|
+
// workspace-scoped ticket over the authenticated REST channel and pass it as
|
|
99
|
+
// `?ticket=`. Empty when auth is disabled (dev) — the handshake is open then.
|
|
100
|
+
let ticket: string
|
|
101
|
+
try {
|
|
102
|
+
ticket = (await api.mintEventsTicket(workspaceId)).ticket
|
|
103
|
+
} catch {
|
|
104
|
+
// Couldn't mint (offline, token lapsed) — back off and retry.
|
|
105
|
+
scheduleReconnect()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
// A workspace switch (or stop()) may have happened while awaiting the mint.
|
|
109
|
+
if (stopped || workspace.workspaceId !== workspaceId) return
|
|
110
|
+
|
|
111
|
+
const query = ticket ? `?ticket=${encodeURIComponent(ticket)}` : ''
|
|
112
|
+
socket = new WebSocket(`${wsBase}/workspaces/${workspaceId}/events${query}`)
|
|
113
|
+
|
|
114
|
+
socket.onopen = () => {
|
|
115
|
+
attempt = 0
|
|
116
|
+
connected.value = true
|
|
117
|
+
// Resync on (re)connect: any event missed while disconnected is reconciled.
|
|
118
|
+
// The snapshot carries `bootstrapJobs` + executions, so one refresh rehydrates
|
|
119
|
+
// agentRuns too — a missed terminal event (e.g. a container eviction that
|
|
120
|
+
// failed the run) can't leave a frame stuck on a stale "bootstrapping…" badge.
|
|
121
|
+
void workspace.refresh()
|
|
122
|
+
}
|
|
123
|
+
socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
|
|
124
|
+
socket.onclose = () => {
|
|
125
|
+
connected.value = false
|
|
126
|
+
scheduleReconnect()
|
|
127
|
+
}
|
|
128
|
+
socket.onerror = () => socket?.close()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scheduleReconnect() {
|
|
132
|
+
if (stopped) return
|
|
133
|
+
socket = null
|
|
134
|
+
const delay = Math.min(30_000, 500 * 2 ** attempt) // 0.5s → 30s cap
|
|
135
|
+
attempt += 1
|
|
136
|
+
reconnectTimer = setTimeout(connect, delay)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function start() {
|
|
140
|
+
stopped = false
|
|
141
|
+
connect()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stop() {
|
|
145
|
+
stopped = true
|
|
146
|
+
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
147
|
+
if (boardDebounce) clearTimeout(boardDebounce)
|
|
148
|
+
socket?.close()
|
|
149
|
+
socket = null
|
|
150
|
+
connected.value = false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onScopeDispose(stop)
|
|
154
|
+
return { start, stop, connected }
|
|
155
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Frontend architecture — state & data flow
|
|
2
|
+
|
|
3
|
+
How the SPA stays in sync with the backend. The app is a **thin client**: it holds
|
|
4
|
+
no business logic, calls the Worker for every mutation, and hydrates its stores
|
|
5
|
+
from server snapshots plus pushed events. For the high-level tour see
|
|
6
|
+
[`../README.md`](../README.md).
|
|
7
|
+
|
|
8
|
+
## The three paths
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
REST (useApi) ─────────────▶ Worker ─────────────▶ D1
|
|
12
|
+
▲ │
|
|
13
|
+
│ mutations │ persisted transition
|
|
14
|
+
stores (Pinia) ◀── patch ── useWorkspaceStream ◀── WebSocket push (events hub)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Read path** — the `workspace` store loads the full snapshot and fans it into
|
|
18
|
+
`board`, `pipelines`, `execution`, `spend`, etc.
|
|
19
|
+
- **Write path** — components call `useApi` → Worker; the response (or a pushed
|
|
20
|
+
event) patches the relevant store. No optimistic business logic.
|
|
21
|
+
- **Live path** — `useWorkspaceStream` opens one WebSocket to
|
|
22
|
+
`GET /workspaces/:ws/events?token=…`, patches `execution` / `agentRuns` /
|
|
23
|
+
`board` as events arrive, and refreshes on reconnect to reconcile anything
|
|
24
|
+
missed.
|
|
25
|
+
|
|
26
|
+
## Stores
|
|
27
|
+
|
|
28
|
+
One Pinia store per feature domain: `workspace`, `accounts`, `auth`, `board`,
|
|
29
|
+
`ui`, `pipelines`, `agents`, `execution`, `agentRuns`, `models`, `github`,
|
|
30
|
+
`bootstrap`, `documents`, `tasks`, `requirements`, `scenarios`, `fragments`
|
|
31
|
+
(built-in catalog), `fragmentLibrary` (tenant tiers + sources), and `spend`.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import BoardCanvas from '~/components/board/BoardCanvas.vue'
|
|
3
|
+
import SideBar from '~/components/layout/SideBar.vue'
|
|
4
|
+
import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
5
|
+
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
6
|
+
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
7
|
+
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
8
|
+
import InspectorPanel from '~/components/panels/InspectorPanel.vue'
|
|
9
|
+
import DecisionModal from '~/components/panels/DecisionModal.vue'
|
|
10
|
+
import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
|
|
11
|
+
import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
|
|
12
|
+
import ObservabilityPanel from '~/components/panels/ObservabilityPanel.vue'
|
|
13
|
+
import BlockFocusView from '~/components/focus/BlockFocusView.vue'
|
|
14
|
+
import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
|
|
15
|
+
import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
|
|
16
|
+
import SpawnPreviewModal from '~/components/documents/SpawnPreviewModal.vue'
|
|
17
|
+
import TaskSourceConnectModal from '~/components/tasks/TaskSourceConnectModal.vue'
|
|
18
|
+
import TaskImportModal from '~/components/tasks/TaskImportModal.vue'
|
|
19
|
+
import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
20
|
+
import RecurringPipelineModal from '~/components/board/RecurringPipelineModal.vue'
|
|
21
|
+
import BootstrapModal from '~/components/bootstrap/BootstrapModal.vue'
|
|
22
|
+
import AddServiceFromRepoModal from '~/components/github/AddServiceFromRepoModal.vue'
|
|
23
|
+
import GitHubPanel from '~/components/github/GitHubPanel.vue'
|
|
24
|
+
import SlackPanel from '~/components/slack/SlackPanel.vue'
|
|
25
|
+
import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
26
|
+
import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
|
|
27
|
+
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
28
|
+
import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
|
|
29
|
+
import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
|
|
30
|
+
import DatadogPanel from '~/components/settings/DatadogPanel.vue'
|
|
31
|
+
import ModelDefaultsPanel from '~/components/settings/ModelDefaultsPanel.vue'
|
|
32
|
+
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
33
|
+
import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
|
|
34
|
+
|
|
35
|
+
const workspace = useWorkspaceStore()
|
|
36
|
+
const github = useGitHubStore()
|
|
37
|
+
|
|
38
|
+
// Load the board from the backend before rendering it.
|
|
39
|
+
onMounted(() => workspace.init())
|
|
40
|
+
|
|
41
|
+
// Probe the GitHub integration as soon as a board is active (re-probe per board —
|
|
42
|
+
// connections are per workspace). The result drives the onboarding gate below
|
|
43
|
+
// before the board mounts, so an unconnected user can't slip past it. SideBar
|
|
44
|
+
// re-probes once it mounts; that duplicate is harmless (probe is idempotent).
|
|
45
|
+
watch(
|
|
46
|
+
() => workspace.workspaceId,
|
|
47
|
+
(id) => {
|
|
48
|
+
if (id) void github.probe()
|
|
49
|
+
},
|
|
50
|
+
{ immediate: true },
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
// Hard gate: the App is enabled on the backend but this workspace has no
|
|
54
|
+
// installation yet. `available === null` means the probe is still in flight.
|
|
55
|
+
const needsGitHubInstall = computed(() => github.available === true && !github.connected)
|
|
56
|
+
const githubProbePending = computed(() => github.available === null)
|
|
57
|
+
|
|
58
|
+
// Subscribe to the backend's real-time event stream and (re)connect whenever the
|
|
59
|
+
// active workspace changes. Runs advance durably server-side; progress arrives as
|
|
60
|
+
// pushed events rather than by polling.
|
|
61
|
+
const stream = useWorkspaceStream()
|
|
62
|
+
watch(
|
|
63
|
+
() => workspace.workspaceId,
|
|
64
|
+
(id) => {
|
|
65
|
+
stream.stop()
|
|
66
|
+
if (id) stream.start()
|
|
67
|
+
},
|
|
68
|
+
{ immediate: true },
|
|
69
|
+
)
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
|
74
|
+
<!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
|
|
75
|
+
<GitHubPatBanner />
|
|
76
|
+
|
|
77
|
+
<!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
|
|
78
|
+
<div
|
|
79
|
+
v-if="workspace.ready && githubProbePending"
|
|
80
|
+
class="m-auto flex flex-col items-center gap-3 text-slate-400"
|
|
81
|
+
>
|
|
82
|
+
<UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
|
|
83
|
+
<span class="text-sm">Loading…</span>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<!-- App enabled but not installed on this workspace: hard onboarding gate. -->
|
|
87
|
+
<GitHubOnboarding v-else-if="workspace.ready && needsGitHubInstall" />
|
|
88
|
+
|
|
89
|
+
<template v-else-if="workspace.ready">
|
|
90
|
+
<SideBar />
|
|
91
|
+
<main class="relative min-w-0 flex-1">
|
|
92
|
+
<BoardCanvas />
|
|
93
|
+
<BoardToolbar />
|
|
94
|
+
<SpendWarningBanner />
|
|
95
|
+
<InspectorPanel />
|
|
96
|
+
<BlockFocusView />
|
|
97
|
+
</main>
|
|
98
|
+
|
|
99
|
+
<PipelineBuilder />
|
|
100
|
+
<DecisionModal />
|
|
101
|
+
<AgentStepDetail />
|
|
102
|
+
<StepResultViewHost />
|
|
103
|
+
<ObservabilityPanel />
|
|
104
|
+
<DocumentSourceConnectModal />
|
|
105
|
+
<DocumentImportModal />
|
|
106
|
+
<SpawnPreviewModal />
|
|
107
|
+
<TaskSourceConnectModal />
|
|
108
|
+
<TaskImportModal />
|
|
109
|
+
<AddTaskModal />
|
|
110
|
+
<RecurringPipelineModal />
|
|
111
|
+
<BootstrapModal />
|
|
112
|
+
<AddServiceFromRepoModal />
|
|
113
|
+
<GitHubPanel />
|
|
114
|
+
<SlackPanel />
|
|
115
|
+
<FragmentLibraryPanel />
|
|
116
|
+
<CommandBar />
|
|
117
|
+
<MergeThresholdsPanel />
|
|
118
|
+
<WorkspaceSettingsPanel />
|
|
119
|
+
<DatadogPanel />
|
|
120
|
+
<ModelDefaultsPanel />
|
|
121
|
+
<ServiceFragmentDefaultsPanel />
|
|
122
|
+
<LocalModelEndpointsPanel />
|
|
123
|
+
</template>
|
|
124
|
+
|
|
125
|
+
<!-- Backend unreachable / bootstrap failed -->
|
|
126
|
+
<div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
|
|
127
|
+
<UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
|
|
128
|
+
<h1 class="mb-1 text-lg font-semibold">Can’t reach the backend</h1>
|
|
129
|
+
<p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
|
|
130
|
+
<UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
|
|
131
|
+
Retry
|
|
132
|
+
</UButton>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- Initial load -->
|
|
136
|
+
<div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
|
|
137
|
+
<UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
|
|
138
|
+
<span class="text-sm">Loading board…</span>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</template>
|