@cat-factory/app 0.6.0 → 0.7.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/LICENSE +21 -21
- package/app/components/board/ContextPicker.vue +367 -367
- package/app/components/gates/GateResultView.vue +109 -4
- package/app/components/layout/SideBar.vue +11 -0
- package/app/components/observability/StepMetricsBar.vue +102 -102
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -178
- package/app/components/panels/inspector/TaskRunSettings.vue +77 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -124
- package/app/components/settings/IssueTrackerWritebackPanel.vue +103 -0
- package/app/composables/useBlockQueries.ts +154 -154
- package/app/composables/useContextLinking.ts +65 -65
- package/app/composables/useFrameResize.ts +54 -54
- package/app/pages/index.vue +2 -0
- package/app/stores/documents.ts +176 -176
- package/app/stores/services.ts +87 -87
- package/app/stores/tracker.ts +39 -27
- package/app/stores/ui.ts +12 -0
- package/app/types/documents.ts +104 -104
- package/app/types/domain.ts +5 -1
- package/app/types/execution.ts +18 -0
- package/app/types/github.ts +173 -173
- package/app/types/services.ts +27 -27
- package/app/types/tasks.ts +82 -82
- package/app/types/tracker.ts +27 -18
- package/app/utils/agentOutput.spec.ts +128 -128
- package/app/utils/agentOutput.ts +173 -173
- package/app/utils/observability.ts +52 -52
- package/package.json +6 -1
|
@@ -1,124 +1,124 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// Edits a `Recurrence`: run every N hours, optionally constrained to a set of
|
|
3
|
-
// weekdays and an hour-of-day window, in a chosen timezone. Used both when adding
|
|
4
|
-
// a recurring pipeline (frame modal) and when editing one (inspector). Emits the
|
|
5
|
-
// updated recurrence via v-model.
|
|
6
|
-
import type { Recurrence } from '~/types/recurring'
|
|
7
|
-
|
|
8
|
-
const props = defineProps<{ modelValue: Recurrence }>()
|
|
9
|
-
const emit = defineEmits<{ 'update:modelValue': [Recurrence] }>()
|
|
10
|
-
|
|
11
|
-
const WEEKDAYS = [
|
|
12
|
-
{ value: 0, label: 'Sun' },
|
|
13
|
-
{ value: 1, label: 'Mon' },
|
|
14
|
-
{ value: 2, label: 'Tue' },
|
|
15
|
-
{ value: 3, label: 'Wed' },
|
|
16
|
-
{ value: 4, label: 'Thu' },
|
|
17
|
-
{ value: 5, label: 'Fri' },
|
|
18
|
-
{ value: 6, label: 'Sat' },
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
function patch(p: Partial<Recurrence>) {
|
|
22
|
-
emit('update:modelValue', { ...props.modelValue, ...p })
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function toggleDay(day: number) {
|
|
26
|
-
const set = new Set(props.modelValue.weekdays)
|
|
27
|
-
if (set.has(day)) set.delete(day)
|
|
28
|
-
else set.add(day)
|
|
29
|
-
patch({ weekdays: [...set].sort((a, b) => a - b) })
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The hour-window is "any hour" when both bounds are null. The checkbox toggles
|
|
33
|
-
// between unconstrained and a default business-hours window.
|
|
34
|
-
const windowEnabled = computed(
|
|
35
|
-
() => props.modelValue.windowStartHour !== null || props.modelValue.windowEndHour !== null,
|
|
36
|
-
)
|
|
37
|
-
function toggleWindow(enabled: boolean) {
|
|
38
|
-
if (enabled) patch({ windowStartHour: 9, windowEndHour: 17 })
|
|
39
|
-
else patch({ windowStartHour: null, windowEndHour: null })
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const hours = Array.from({ length: 24 }, (_, h) => ({
|
|
43
|
-
value: h,
|
|
44
|
-
label: `${String(h).padStart(2, '0')}:00`,
|
|
45
|
-
}))
|
|
46
|
-
|
|
47
|
-
// A small, common set of IANA zones plus whatever the schedule already uses.
|
|
48
|
-
const TIMEZONES = [
|
|
49
|
-
'UTC',
|
|
50
|
-
'Europe/London',
|
|
51
|
-
'Europe/Helsinki',
|
|
52
|
-
'America/New_York',
|
|
53
|
-
'America/Los_Angeles',
|
|
54
|
-
'Asia/Tokyo',
|
|
55
|
-
]
|
|
56
|
-
const timezoneOptions = computed(() =>
|
|
57
|
-
Array.from(new Set([props.modelValue.timezone, ...TIMEZONES])),
|
|
58
|
-
)
|
|
59
|
-
</script>
|
|
60
|
-
|
|
61
|
-
<template>
|
|
62
|
-
<div class="space-y-3">
|
|
63
|
-
<UFormField label="Run every">
|
|
64
|
-
<div class="flex items-center gap-2">
|
|
65
|
-
<UInput
|
|
66
|
-
:model-value="modelValue.intervalHours"
|
|
67
|
-
type="number"
|
|
68
|
-
:min="1"
|
|
69
|
-
class="w-24"
|
|
70
|
-
@update:model-value="patch({ intervalHours: Math.max(1, Number($event) || 1) })"
|
|
71
|
-
/>
|
|
72
|
-
<span class="text-xs text-slate-400">hours</span>
|
|
73
|
-
</div>
|
|
74
|
-
</UFormField>
|
|
75
|
-
|
|
76
|
-
<UFormField label="Allowed days" help="Leave all off to run any day.">
|
|
77
|
-
<div class="flex flex-wrap gap-1">
|
|
78
|
-
<UButton
|
|
79
|
-
v-for="d in WEEKDAYS"
|
|
80
|
-
:key="d.value"
|
|
81
|
-
size="xs"
|
|
82
|
-
:color="modelValue.weekdays.includes(d.value) ? 'primary' : 'neutral'"
|
|
83
|
-
:variant="modelValue.weekdays.includes(d.value) ? 'solid' : 'subtle'"
|
|
84
|
-
@click="toggleDay(d.value)"
|
|
85
|
-
>
|
|
86
|
-
{{ d.label }}
|
|
87
|
-
</UButton>
|
|
88
|
-
</div>
|
|
89
|
-
</UFormField>
|
|
90
|
-
|
|
91
|
-
<UFormField>
|
|
92
|
-
<UCheckbox
|
|
93
|
-
:model-value="windowEnabled"
|
|
94
|
-
label="Only within an hour-of-day window (e.g. business hours)"
|
|
95
|
-
@update:model-value="toggleWindow(Boolean($event))"
|
|
96
|
-
/>
|
|
97
|
-
</UFormField>
|
|
98
|
-
|
|
99
|
-
<div v-if="windowEnabled" class="flex items-center gap-2">
|
|
100
|
-
<USelect
|
|
101
|
-
:model-value="modelValue.windowStartHour ?? 0"
|
|
102
|
-
:items="hours"
|
|
103
|
-
class="w-28"
|
|
104
|
-
@update:model-value="patch({ windowStartHour: Number($event) })"
|
|
105
|
-
/>
|
|
106
|
-
<span class="text-xs text-slate-400">to</span>
|
|
107
|
-
<USelect
|
|
108
|
-
:model-value="modelValue.windowEndHour ?? 24 % 24"
|
|
109
|
-
:items="hours"
|
|
110
|
-
class="w-28"
|
|
111
|
-
@update:model-value="patch({ windowEndHour: Number($event) })"
|
|
112
|
-
/>
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<UFormField label="Timezone">
|
|
116
|
-
<USelect
|
|
117
|
-
:model-value="modelValue.timezone"
|
|
118
|
-
:items="timezoneOptions"
|
|
119
|
-
class="w-full"
|
|
120
|
-
@update:model-value="patch({ timezone: String($event) })"
|
|
121
|
-
/>
|
|
122
|
-
</UFormField>
|
|
123
|
-
</div>
|
|
124
|
-
</template>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Edits a `Recurrence`: run every N hours, optionally constrained to a set of
|
|
3
|
+
// weekdays and an hour-of-day window, in a chosen timezone. Used both when adding
|
|
4
|
+
// a recurring pipeline (frame modal) and when editing one (inspector). Emits the
|
|
5
|
+
// updated recurrence via v-model.
|
|
6
|
+
import type { Recurrence } from '~/types/recurring'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ modelValue: Recurrence }>()
|
|
9
|
+
const emit = defineEmits<{ 'update:modelValue': [Recurrence] }>()
|
|
10
|
+
|
|
11
|
+
const WEEKDAYS = [
|
|
12
|
+
{ value: 0, label: 'Sun' },
|
|
13
|
+
{ value: 1, label: 'Mon' },
|
|
14
|
+
{ value: 2, label: 'Tue' },
|
|
15
|
+
{ value: 3, label: 'Wed' },
|
|
16
|
+
{ value: 4, label: 'Thu' },
|
|
17
|
+
{ value: 5, label: 'Fri' },
|
|
18
|
+
{ value: 6, label: 'Sat' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
function patch(p: Partial<Recurrence>) {
|
|
22
|
+
emit('update:modelValue', { ...props.modelValue, ...p })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toggleDay(day: number) {
|
|
26
|
+
const set = new Set(props.modelValue.weekdays)
|
|
27
|
+
if (set.has(day)) set.delete(day)
|
|
28
|
+
else set.add(day)
|
|
29
|
+
patch({ weekdays: [...set].sort((a, b) => a - b) })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The hour-window is "any hour" when both bounds are null. The checkbox toggles
|
|
33
|
+
// between unconstrained and a default business-hours window.
|
|
34
|
+
const windowEnabled = computed(
|
|
35
|
+
() => props.modelValue.windowStartHour !== null || props.modelValue.windowEndHour !== null,
|
|
36
|
+
)
|
|
37
|
+
function toggleWindow(enabled: boolean) {
|
|
38
|
+
if (enabled) patch({ windowStartHour: 9, windowEndHour: 17 })
|
|
39
|
+
else patch({ windowStartHour: null, windowEndHour: null })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hours = Array.from({ length: 24 }, (_, h) => ({
|
|
43
|
+
value: h,
|
|
44
|
+
label: `${String(h).padStart(2, '0')}:00`,
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
// A small, common set of IANA zones plus whatever the schedule already uses.
|
|
48
|
+
const TIMEZONES = [
|
|
49
|
+
'UTC',
|
|
50
|
+
'Europe/London',
|
|
51
|
+
'Europe/Helsinki',
|
|
52
|
+
'America/New_York',
|
|
53
|
+
'America/Los_Angeles',
|
|
54
|
+
'Asia/Tokyo',
|
|
55
|
+
]
|
|
56
|
+
const timezoneOptions = computed(() =>
|
|
57
|
+
Array.from(new Set([props.modelValue.timezone, ...TIMEZONES])),
|
|
58
|
+
)
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<div class="space-y-3">
|
|
63
|
+
<UFormField label="Run every">
|
|
64
|
+
<div class="flex items-center gap-2">
|
|
65
|
+
<UInput
|
|
66
|
+
:model-value="modelValue.intervalHours"
|
|
67
|
+
type="number"
|
|
68
|
+
:min="1"
|
|
69
|
+
class="w-24"
|
|
70
|
+
@update:model-value="patch({ intervalHours: Math.max(1, Number($event) || 1) })"
|
|
71
|
+
/>
|
|
72
|
+
<span class="text-xs text-slate-400">hours</span>
|
|
73
|
+
</div>
|
|
74
|
+
</UFormField>
|
|
75
|
+
|
|
76
|
+
<UFormField label="Allowed days" help="Leave all off to run any day.">
|
|
77
|
+
<div class="flex flex-wrap gap-1">
|
|
78
|
+
<UButton
|
|
79
|
+
v-for="d in WEEKDAYS"
|
|
80
|
+
:key="d.value"
|
|
81
|
+
size="xs"
|
|
82
|
+
:color="modelValue.weekdays.includes(d.value) ? 'primary' : 'neutral'"
|
|
83
|
+
:variant="modelValue.weekdays.includes(d.value) ? 'solid' : 'subtle'"
|
|
84
|
+
@click="toggleDay(d.value)"
|
|
85
|
+
>
|
|
86
|
+
{{ d.label }}
|
|
87
|
+
</UButton>
|
|
88
|
+
</div>
|
|
89
|
+
</UFormField>
|
|
90
|
+
|
|
91
|
+
<UFormField>
|
|
92
|
+
<UCheckbox
|
|
93
|
+
:model-value="windowEnabled"
|
|
94
|
+
label="Only within an hour-of-day window (e.g. business hours)"
|
|
95
|
+
@update:model-value="toggleWindow(Boolean($event))"
|
|
96
|
+
/>
|
|
97
|
+
</UFormField>
|
|
98
|
+
|
|
99
|
+
<div v-if="windowEnabled" class="flex items-center gap-2">
|
|
100
|
+
<USelect
|
|
101
|
+
:model-value="modelValue.windowStartHour ?? 0"
|
|
102
|
+
:items="hours"
|
|
103
|
+
class="w-28"
|
|
104
|
+
@update:model-value="patch({ windowStartHour: Number($event) })"
|
|
105
|
+
/>
|
|
106
|
+
<span class="text-xs text-slate-400">to</span>
|
|
107
|
+
<USelect
|
|
108
|
+
:model-value="modelValue.windowEndHour ?? 24 % 24"
|
|
109
|
+
:items="hours"
|
|
110
|
+
class="w-28"
|
|
111
|
+
@update:model-value="patch({ windowEndHour: Number($event) })"
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<UFormField label="Timezone">
|
|
116
|
+
<USelect
|
|
117
|
+
:model-value="modelValue.timezone"
|
|
118
|
+
:items="timezoneOptions"
|
|
119
|
+
class="w-full"
|
|
120
|
+
@update:model-value="patch({ timezone: String($event) })"
|
|
121
|
+
/>
|
|
122
|
+
</UFormField>
|
|
123
|
+
</div>
|
|
124
|
+
</template>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Workspace settings: issue-tracker writeback. Two independent toggles that govern
|
|
3
|
+
// whether the engine writes back to a task's linked tracker issue(s) as its PR
|
|
4
|
+
// progresses — comment when the PR opens, and comment + close as resolved when it
|
|
5
|
+
// merges. Each is overridable per task in the inspector. Persisted on the workspace
|
|
6
|
+
// tracker settings (the selection + Jira project key are preserved on save).
|
|
7
|
+
import { ref, watch } from 'vue'
|
|
8
|
+
|
|
9
|
+
const ui = useUiStore()
|
|
10
|
+
const tracker = useTrackerStore()
|
|
11
|
+
const toast = useToast()
|
|
12
|
+
|
|
13
|
+
const open = computed({
|
|
14
|
+
get: () => ui.issueWritebackOpen,
|
|
15
|
+
set: (v: boolean) => (v ? ui.openIssueWriteback() : ui.closeIssueWriteback()),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const commentOnPrOpen = ref(false)
|
|
19
|
+
const resolveOnMerge = ref(false)
|
|
20
|
+
const saving = ref(false)
|
|
21
|
+
|
|
22
|
+
// Re-sync the local toggles from the store whenever the panel opens.
|
|
23
|
+
watch(open, (isOpen) => {
|
|
24
|
+
if (!isOpen) return
|
|
25
|
+
commentOnPrOpen.value = tracker.settings.writebackCommentOnPrOpen
|
|
26
|
+
resolveOnMerge.value = tracker.settings.writebackResolveOnMerge
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
async function save() {
|
|
30
|
+
saving.value = true
|
|
31
|
+
try {
|
|
32
|
+
// Preserve the tracker selection + Jira project key; only the writeback flags change.
|
|
33
|
+
await tracker.save({
|
|
34
|
+
tracker: tracker.settings.tracker,
|
|
35
|
+
jiraProjectKey: tracker.settings.jiraProjectKey,
|
|
36
|
+
writebackCommentOnPrOpen: commentOnPrOpen.value,
|
|
37
|
+
writebackResolveOnMerge: resolveOnMerge.value,
|
|
38
|
+
})
|
|
39
|
+
toast.add({ title: 'Writeback settings saved', icon: 'i-lucide-check', color: 'success' })
|
|
40
|
+
} catch (e) {
|
|
41
|
+
toast.add({
|
|
42
|
+
title: 'Could not save settings',
|
|
43
|
+
description: e instanceof Error ? e.message : String(e),
|
|
44
|
+
icon: 'i-lucide-triangle-alert',
|
|
45
|
+
color: 'error',
|
|
46
|
+
})
|
|
47
|
+
} finally {
|
|
48
|
+
saving.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<UModal v-model:open="open" title="Issue tracker writeback" :ui="{ content: 'max-w-lg' }">
|
|
55
|
+
<template #body>
|
|
56
|
+
<div class="space-y-4">
|
|
57
|
+
<p class="text-xs text-slate-400">
|
|
58
|
+
When a task is linked to a tracker issue (GitHub Issues or Jira), write back to it as the
|
|
59
|
+
task's pull request progresses. Each toggle is the workspace default and can be overridden
|
|
60
|
+
per task in the inspector. GitHub issues close natively; Jira issues transition to the
|
|
61
|
+
first status in their <span class="text-slate-300">Done</span> category.
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<label
|
|
65
|
+
class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3"
|
|
66
|
+
>
|
|
67
|
+
<USwitch v-model="commentOnPrOpen" />
|
|
68
|
+
<span class="text-sm">
|
|
69
|
+
<span class="block text-slate-200">Comment when a PR opens</span>
|
|
70
|
+
<span class="block text-xs text-slate-500">
|
|
71
|
+
Post a comment on the linked issue with the new pull request's link.
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
</label>
|
|
75
|
+
|
|
76
|
+
<label
|
|
77
|
+
class="flex items-start gap-3 rounded-lg border border-slate-700 bg-slate-800/40 p-3"
|
|
78
|
+
>
|
|
79
|
+
<USwitch v-model="resolveOnMerge" />
|
|
80
|
+
<span class="text-sm">
|
|
81
|
+
<span class="block text-slate-200">Close as resolved when a PR merges</span>
|
|
82
|
+
<span class="block text-xs text-slate-500">
|
|
83
|
+
Comment that the PR merged, then close / resolve the linked issue.
|
|
84
|
+
</span>
|
|
85
|
+
</span>
|
|
86
|
+
</label>
|
|
87
|
+
|
|
88
|
+
<div class="flex justify-end">
|
|
89
|
+
<UButton
|
|
90
|
+
color="primary"
|
|
91
|
+
variant="soft"
|
|
92
|
+
size="sm"
|
|
93
|
+
icon="i-lucide-save"
|
|
94
|
+
:loading="saving"
|
|
95
|
+
@click="save"
|
|
96
|
+
>
|
|
97
|
+
Save
|
|
98
|
+
</UButton>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</template>
|
|
102
|
+
</UModal>
|
|
103
|
+
</template>
|
|
@@ -1,154 +1,154 @@
|
|
|
1
|
-
import { computed, type Ref } from 'vue'
|
|
2
|
-
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Pure, read-only queries over a board's blocks. Extracted from the board store
|
|
6
|
-
* so the (sizeable) derivation logic — hierarchy traversal, status/progress
|
|
7
|
-
* rollups and container sizing — lives in one focused, independently testable
|
|
8
|
-
* place. The store wires these against its reactive `blocks` cache and re-exposes
|
|
9
|
-
* them unchanged, so callers and tests are unaffected.
|
|
10
|
-
*/
|
|
11
|
-
export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
12
|
-
const byId = computed(() => {
|
|
13
|
-
const map = new Map<string, Block>()
|
|
14
|
-
for (const b of blocks.value) map.set(b.id, b)
|
|
15
|
-
return map
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
function getBlock(id: string) {
|
|
19
|
-
return byId.value.get(id)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
|
|
23
|
-
const frames = computed(() => blocks.value.filter((b) => (b.level ?? 'frame') === 'frame'))
|
|
24
|
-
|
|
25
|
-
/** Direct children of a block, in insertion order. */
|
|
26
|
-
function childrenOf(parentId: string) {
|
|
27
|
-
return blocks.value.filter((b) => b.parentId === parentId)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Tasks directly inside a container (a service or a module). */
|
|
31
|
-
function tasksOf(containerId: string) {
|
|
32
|
-
return blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Modules (sub-frames) inside a service. */
|
|
36
|
-
function modulesOf(serviceId: string) {
|
|
37
|
-
return blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Tasks anywhere under a container — directly, or nested inside its modules. */
|
|
41
|
-
function allTasksUnder(containerId: string): Block[] {
|
|
42
|
-
const direct = tasksOf(containerId)
|
|
43
|
-
const nested = modulesOf(containerId).flatMap((m) => tasksOf(m.id))
|
|
44
|
-
return [...direct, ...nested]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The top-level service a block ultimately belongs to. */
|
|
48
|
-
function serviceOf(block: Block): Block | undefined {
|
|
49
|
-
let cur: Block | undefined = block
|
|
50
|
-
while (cur && cur.level !== 'frame') {
|
|
51
|
-
cur = cur.parentId ? getBlock(cur.parentId) : undefined
|
|
52
|
-
}
|
|
53
|
-
return cur
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** All tasks across every service (used for the dependency picker). */
|
|
57
|
-
const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
|
|
58
|
-
|
|
59
|
-
/** A task's dependencies that are not yet merged (i.e. block it from running). */
|
|
60
|
-
function unmetDeps(taskId: string) {
|
|
61
|
-
const t = getBlock(taskId)
|
|
62
|
-
if (!t) return [] as Block[]
|
|
63
|
-
return t.dependsOn
|
|
64
|
-
.map((id) => getBlock(id))
|
|
65
|
-
.filter((b): b is Block => !!b && b.status !== 'done')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** A task may run only once all of its dependencies have merged. */
|
|
69
|
-
function isRunnable(taskId: string) {
|
|
70
|
-
return unmetDeps(taskId).length === 0
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Container status/progress are derived from the tasks under it (containers have no PR). */
|
|
74
|
-
function frameProgress(frameId: string) {
|
|
75
|
-
const tasks = allTasksUnder(frameId)
|
|
76
|
-
if (tasks.length === 0) return getBlock(frameId)?.progress ?? 0
|
|
77
|
-
const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 1 : t.progress), 0)
|
|
78
|
-
return sum / tasks.length
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* A frame is a long-lived service: it never reaches a terminal "done" —
|
|
83
|
-
* tasks keep appearing. So its status reflects current *activity*, mapped
|
|
84
|
-
* onto the shared status palette but capped below `done`:
|
|
85
|
-
* planned → no tasks yet (empty)
|
|
86
|
-
* ready → has tasks but nothing active (idle / caught up / "live")
|
|
87
|
-
* in_progress → at least one task running or with an open PR
|
|
88
|
-
* blocked → at least one task needs a decision
|
|
89
|
-
*/
|
|
90
|
-
function frameStatus(frameId: string): BlockStatus {
|
|
91
|
-
const tasks = allTasksUnder(frameId)
|
|
92
|
-
if (tasks.length === 0) return 'planned'
|
|
93
|
-
if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
|
|
94
|
-
if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
|
|
95
|
-
return 'in_progress'
|
|
96
|
-
return 'ready'
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The natural extent of a container's inner 2D canvas — the smallest size that
|
|
101
|
-
* still fits all its children. This is the floor a resizable frame can never be
|
|
102
|
-
* dragged below (so tasks/modules are never clipped).
|
|
103
|
-
*/
|
|
104
|
-
function contentSize(id: string): { w: number; h: number } {
|
|
105
|
-
const b = getBlock(id)
|
|
106
|
-
const isModule = b?.level === 'module'
|
|
107
|
-
const TASK_W = 180
|
|
108
|
-
const TASK_H = 160
|
|
109
|
-
const headerH = isModule ? 30 : 0
|
|
110
|
-
let w = isModule ? 200 : 360
|
|
111
|
-
let inner = isModule ? 60 : 220
|
|
112
|
-
for (const t of tasksOf(id)) {
|
|
113
|
-
w = Math.max(w, t.position.x + TASK_W + 12)
|
|
114
|
-
inner = Math.max(inner, t.position.y + TASK_H + 12)
|
|
115
|
-
}
|
|
116
|
-
for (const m of modulesOf(id)) {
|
|
117
|
-
const s = containerSize(m.id)
|
|
118
|
-
w = Math.max(w, m.position.x + s.w + 12)
|
|
119
|
-
inner = Math.max(inner, m.position.y + s.h + 12)
|
|
120
|
-
}
|
|
121
|
-
return { w, h: inner + headerH }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Pixel size of a container's inner 2D canvas. The content extent is the floor;
|
|
126
|
-
* a frame the user has resized (dragging its borders) uses its stored `size`
|
|
127
|
-
* when that is larger, so an explicit size grows the frame but never shrinks it
|
|
128
|
-
* below its contents.
|
|
129
|
-
*/
|
|
130
|
-
function containerSize(id: string): { w: number; h: number } {
|
|
131
|
-
const content = contentSize(id)
|
|
132
|
-
const stored = getBlock(id)?.size
|
|
133
|
-
if (!stored) return content
|
|
134
|
-
return { w: Math.max(content.w, stored.w), h: Math.max(content.h, stored.h) }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return {
|
|
138
|
-
byId,
|
|
139
|
-
getBlock,
|
|
140
|
-
frames,
|
|
141
|
-
allTasks,
|
|
142
|
-
childrenOf,
|
|
143
|
-
tasksOf,
|
|
144
|
-
modulesOf,
|
|
145
|
-
allTasksUnder,
|
|
146
|
-
serviceOf,
|
|
147
|
-
unmetDeps,
|
|
148
|
-
isRunnable,
|
|
149
|
-
frameProgress,
|
|
150
|
-
frameStatus,
|
|
151
|
-
contentSize,
|
|
152
|
-
containerSize,
|
|
153
|
-
}
|
|
154
|
-
}
|
|
1
|
+
import { computed, type Ref } from 'vue'
|
|
2
|
+
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure, read-only queries over a board's blocks. Extracted from the board store
|
|
6
|
+
* so the (sizeable) derivation logic — hierarchy traversal, status/progress
|
|
7
|
+
* rollups and container sizing — lives in one focused, independently testable
|
|
8
|
+
* place. The store wires these against its reactive `blocks` cache and re-exposes
|
|
9
|
+
* them unchanged, so callers and tests are unaffected.
|
|
10
|
+
*/
|
|
11
|
+
export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
12
|
+
const byId = computed(() => {
|
|
13
|
+
const map = new Map<string, Block>()
|
|
14
|
+
for (const b of blocks.value) map.set(b.id, b)
|
|
15
|
+
return map
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function getBlock(id: string) {
|
|
19
|
+
return byId.value.get(id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
|
|
23
|
+
const frames = computed(() => blocks.value.filter((b) => (b.level ?? 'frame') === 'frame'))
|
|
24
|
+
|
|
25
|
+
/** Direct children of a block, in insertion order. */
|
|
26
|
+
function childrenOf(parentId: string) {
|
|
27
|
+
return blocks.value.filter((b) => b.parentId === parentId)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Tasks directly inside a container (a service or a module). */
|
|
31
|
+
function tasksOf(containerId: string) {
|
|
32
|
+
return blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Modules (sub-frames) inside a service. */
|
|
36
|
+
function modulesOf(serviceId: string) {
|
|
37
|
+
return blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tasks anywhere under a container — directly, or nested inside its modules. */
|
|
41
|
+
function allTasksUnder(containerId: string): Block[] {
|
|
42
|
+
const direct = tasksOf(containerId)
|
|
43
|
+
const nested = modulesOf(containerId).flatMap((m) => tasksOf(m.id))
|
|
44
|
+
return [...direct, ...nested]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The top-level service a block ultimately belongs to. */
|
|
48
|
+
function serviceOf(block: Block): Block | undefined {
|
|
49
|
+
let cur: Block | undefined = block
|
|
50
|
+
while (cur && cur.level !== 'frame') {
|
|
51
|
+
cur = cur.parentId ? getBlock(cur.parentId) : undefined
|
|
52
|
+
}
|
|
53
|
+
return cur
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** All tasks across every service (used for the dependency picker). */
|
|
57
|
+
const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
|
|
58
|
+
|
|
59
|
+
/** A task's dependencies that are not yet merged (i.e. block it from running). */
|
|
60
|
+
function unmetDeps(taskId: string) {
|
|
61
|
+
const t = getBlock(taskId)
|
|
62
|
+
if (!t) return [] as Block[]
|
|
63
|
+
return t.dependsOn
|
|
64
|
+
.map((id) => getBlock(id))
|
|
65
|
+
.filter((b): b is Block => !!b && b.status !== 'done')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A task may run only once all of its dependencies have merged. */
|
|
69
|
+
function isRunnable(taskId: string) {
|
|
70
|
+
return unmetDeps(taskId).length === 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Container status/progress are derived from the tasks under it (containers have no PR). */
|
|
74
|
+
function frameProgress(frameId: string) {
|
|
75
|
+
const tasks = allTasksUnder(frameId)
|
|
76
|
+
if (tasks.length === 0) return getBlock(frameId)?.progress ?? 0
|
|
77
|
+
const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 1 : t.progress), 0)
|
|
78
|
+
return sum / tasks.length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A frame is a long-lived service: it never reaches a terminal "done" —
|
|
83
|
+
* tasks keep appearing. So its status reflects current *activity*, mapped
|
|
84
|
+
* onto the shared status palette but capped below `done`:
|
|
85
|
+
* planned → no tasks yet (empty)
|
|
86
|
+
* ready → has tasks but nothing active (idle / caught up / "live")
|
|
87
|
+
* in_progress → at least one task running or with an open PR
|
|
88
|
+
* blocked → at least one task needs a decision
|
|
89
|
+
*/
|
|
90
|
+
function frameStatus(frameId: string): BlockStatus {
|
|
91
|
+
const tasks = allTasksUnder(frameId)
|
|
92
|
+
if (tasks.length === 0) return 'planned'
|
|
93
|
+
if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
|
|
94
|
+
if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
|
|
95
|
+
return 'in_progress'
|
|
96
|
+
return 'ready'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The natural extent of a container's inner 2D canvas — the smallest size that
|
|
101
|
+
* still fits all its children. This is the floor a resizable frame can never be
|
|
102
|
+
* dragged below (so tasks/modules are never clipped).
|
|
103
|
+
*/
|
|
104
|
+
function contentSize(id: string): { w: number; h: number } {
|
|
105
|
+
const b = getBlock(id)
|
|
106
|
+
const isModule = b?.level === 'module'
|
|
107
|
+
const TASK_W = 180
|
|
108
|
+
const TASK_H = 160
|
|
109
|
+
const headerH = isModule ? 30 : 0
|
|
110
|
+
let w = isModule ? 200 : 360
|
|
111
|
+
let inner = isModule ? 60 : 220
|
|
112
|
+
for (const t of tasksOf(id)) {
|
|
113
|
+
w = Math.max(w, t.position.x + TASK_W + 12)
|
|
114
|
+
inner = Math.max(inner, t.position.y + TASK_H + 12)
|
|
115
|
+
}
|
|
116
|
+
for (const m of modulesOf(id)) {
|
|
117
|
+
const s = containerSize(m.id)
|
|
118
|
+
w = Math.max(w, m.position.x + s.w + 12)
|
|
119
|
+
inner = Math.max(inner, m.position.y + s.h + 12)
|
|
120
|
+
}
|
|
121
|
+
return { w, h: inner + headerH }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pixel size of a container's inner 2D canvas. The content extent is the floor;
|
|
126
|
+
* a frame the user has resized (dragging its borders) uses its stored `size`
|
|
127
|
+
* when that is larger, so an explicit size grows the frame but never shrinks it
|
|
128
|
+
* below its contents.
|
|
129
|
+
*/
|
|
130
|
+
function containerSize(id: string): { w: number; h: number } {
|
|
131
|
+
const content = contentSize(id)
|
|
132
|
+
const stored = getBlock(id)?.size
|
|
133
|
+
if (!stored) return content
|
|
134
|
+
return { w: Math.max(content.w, stored.w), h: Math.max(content.h, stored.h) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
byId,
|
|
139
|
+
getBlock,
|
|
140
|
+
frames,
|
|
141
|
+
allTasks,
|
|
142
|
+
childrenOf,
|
|
143
|
+
tasksOf,
|
|
144
|
+
modulesOf,
|
|
145
|
+
allTasksUnder,
|
|
146
|
+
serviceOf,
|
|
147
|
+
unmetDeps,
|
|
148
|
+
isRunnable,
|
|
149
|
+
frameProgress,
|
|
150
|
+
frameStatus,
|
|
151
|
+
contentSize,
|
|
152
|
+
containerSize,
|
|
153
|
+
}
|
|
154
|
+
}
|