@flowdular/sandbox 0.2.5 → 0.2.7
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/README.md +10 -0
- package/bin/flowdular-sandbox.mjs +62 -13
- package/internal/coding-agent/src/drivers/byok.ts +17 -3
- package/internal/coding-agent/src/drivers/claude-code.ts +36 -8
- package/internal/coding-agent/src/drivers/codex.ts +8 -0
- package/internal/coding-agent/src/roles/contract.ts +2 -1
- package/internal/coding-agent/src/roles/registry.ts +1 -7
- package/internal/coding-agent/src/types.ts +2 -0
- package/internal/coding-agent/src/workspace.ts +21 -5
- package/package.json +1 -1
- package/src/client/ChatPane.tsrx +77 -45
- package/src/client/ComposerSettings.tsrx +99 -0
- package/src/client/ToolEvent.tsrx +77 -0
- package/src/client/TurnActivity.tsrx +26 -0
- package/src/client/locales/en.json +21 -1
- package/src/client/locales/pl.json +21 -1
- package/src/client/transcript.ts +110 -0
- package/src/server/auto-review.ts +2 -1
- package/src/server/gate-repair.ts +88 -0
- package/src/server/planning.ts +15 -5
- package/src/server/preview-hot-updates.ts +40 -0
- package/src/server/reference.ts +1 -1
- package/src/server/reload-log.ts +68 -0
- package/src/server/turns.ts +23 -5
- package/src/styles.css +108 -6
- package/vite.config.ts +5 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'octane';
|
|
2
|
+
import { Icon } from '@flowdular/sdk/ui';
|
|
3
|
+
import { useTranslation } from './i18n.ts';
|
|
4
|
+
|
|
5
|
+
export function ComposerSettings(props: {
|
|
6
|
+
readonly autoContinue: boolean;
|
|
7
|
+
readonly freshContext: boolean;
|
|
8
|
+
readonly running: boolean;
|
|
9
|
+
readonly onAutoContinue: (enabled: boolean) => void;
|
|
10
|
+
readonly onFreshContext: (enabled: boolean) => void;
|
|
11
|
+
}) @{
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
14
|
+
const trigger = useRef<HTMLButtonElement | null>(null);
|
|
15
|
+
const [position, setPosition] = useState<
|
|
16
|
+
{ left: number; bottom: number } | null
|
|
17
|
+
>(null);
|
|
18
|
+
const toggle = () => {
|
|
19
|
+
if (position) return setPosition(null);
|
|
20
|
+
const rect = trigger.current?.getBoundingClientRect();
|
|
21
|
+
if (!rect) return;
|
|
22
|
+
setPosition({
|
|
23
|
+
left: Math.max(16, Math.min(rect.left, window.innerWidth - 336)),
|
|
24
|
+
bottom: window.innerHeight - rect.top + 8,
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!position) return;
|
|
29
|
+
const dismiss = (event: PointerEvent | FocusEvent) => {
|
|
30
|
+
if (!root.current?.contains(event.target as Node)) setPosition(null);
|
|
31
|
+
};
|
|
32
|
+
const escape = (event: KeyboardEvent) => {
|
|
33
|
+
if (event.key !== 'Escape') return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
setPosition(null);
|
|
36
|
+
trigger.current?.focus();
|
|
37
|
+
};
|
|
38
|
+
const resize = () => setPosition(null);
|
|
39
|
+
document.addEventListener('pointerdown', dismiss);
|
|
40
|
+
document.addEventListener('focusin', dismiss);
|
|
41
|
+
document.addEventListener('keydown', escape);
|
|
42
|
+
window.addEventListener('resize', resize);
|
|
43
|
+
return () => {
|
|
44
|
+
document.removeEventListener('pointerdown', dismiss);
|
|
45
|
+
document.removeEventListener('focusin', dismiss);
|
|
46
|
+
document.removeEventListener('keydown', escape);
|
|
47
|
+
window.removeEventListener('resize', resize);
|
|
48
|
+
};
|
|
49
|
+
}, [position]);
|
|
50
|
+
|
|
51
|
+
<div ref={root} class="chat-settings">
|
|
52
|
+
<button
|
|
53
|
+
ref={trigger}
|
|
54
|
+
class="ui-btn ui-btn--ghost ui-btn--sm ui-btn--icon"
|
|
55
|
+
type="button"
|
|
56
|
+
aria-expanded={position !== null}
|
|
57
|
+
aria-controls="composer-settings"
|
|
58
|
+
aria-label={t('sandbox.chat.settings')}
|
|
59
|
+
title={t('sandbox.chat.settings')}
|
|
60
|
+
onClick={toggle}
|
|
61
|
+
>
|
|
62
|
+
<Icon name="settings" size={14} />
|
|
63
|
+
</button>
|
|
64
|
+
@if (position) {
|
|
65
|
+
<div
|
|
66
|
+
id="composer-settings"
|
|
67
|
+
class="ui-menu chat-settings__panel"
|
|
68
|
+
role="group"
|
|
69
|
+
aria-label={t('sandbox.chat.settings')}
|
|
70
|
+
style={{ left: position.left + 'px', bottom: position.bottom + 'px' }}
|
|
71
|
+
>
|
|
72
|
+
<label class="ui-menu__item ui-checkbox">
|
|
73
|
+
<input
|
|
74
|
+
type="checkbox"
|
|
75
|
+
checked={props.autoContinue}
|
|
76
|
+
onChange={(event) => props.onAutoContinue(
|
|
77
|
+
event.currentTarget.checked,
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
<span>{t('sandbox.chat.autoHandoff')}</span>
|
|
81
|
+
</label>
|
|
82
|
+
<label
|
|
83
|
+
class="ui-menu__item ui-checkbox"
|
|
84
|
+
title={t('sandbox.chat.freshHelp')}
|
|
85
|
+
>
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={props.freshContext}
|
|
89
|
+
disabled={props.running}
|
|
90
|
+
onChange={(event) => props.onFreshContext(
|
|
91
|
+
event.currentTarget.checked,
|
|
92
|
+
)}
|
|
93
|
+
/>
|
|
94
|
+
<span>{t('sandbox.chat.fresh')}</span>
|
|
95
|
+
</label>
|
|
96
|
+
</div>
|
|
97
|
+
}
|
|
98
|
+
</div>
|
|
99
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Icon } from '@flowdular/sdk/ui';
|
|
2
|
+
import { useTranslation } from './i18n.ts';
|
|
3
|
+
import {
|
|
4
|
+
shortToolDetail,
|
|
5
|
+
toolAction,
|
|
6
|
+
type TranscriptRow,
|
|
7
|
+
} from './transcript.ts';
|
|
8
|
+
|
|
9
|
+
export function ToolEvent(props: {
|
|
10
|
+
readonly row: TranscriptRow;
|
|
11
|
+
readonly running: boolean;
|
|
12
|
+
}) @{
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const event = props.row.entry.event!;
|
|
15
|
+
const original = props.row.started?.event ?? event;
|
|
16
|
+
if (
|
|
17
|
+
event.type !== 'tool.started' && event.type !== 'tool.completed' ||
|
|
18
|
+
original.type !== 'tool.started' && original.type !== 'tool.completed'
|
|
19
|
+
) return;
|
|
20
|
+
const status =
|
|
21
|
+
event.type === 'tool.completed'
|
|
22
|
+
? event.ok
|
|
23
|
+
? 'done'
|
|
24
|
+
: 'failed'
|
|
25
|
+
: props.row.pending && props.running
|
|
26
|
+
? 'running'
|
|
27
|
+
: 'unknown';
|
|
28
|
+
const detail = original.detail || event.detail;
|
|
29
|
+
const action = toolAction(original.tool);
|
|
30
|
+
const target =
|
|
31
|
+
action === 'command' ? detail : shortToolDetail(detail);
|
|
32
|
+
const duration =
|
|
33
|
+
props.row.started
|
|
34
|
+
? (Math.max(0, props.row.entry.at - props.row.started.at) / 1000).toFixed(
|
|
35
|
+
1,
|
|
36
|
+
)
|
|
37
|
+
: null;
|
|
38
|
+
<details class={['chat-tool', 'chat-tool--' + status]} data-status={status}>
|
|
39
|
+
<summary class="chat-tool__summary">
|
|
40
|
+
<Icon
|
|
41
|
+
name={status === 'running'
|
|
42
|
+
? 'refresh'
|
|
43
|
+
: status === 'done'
|
|
44
|
+
? 'check'
|
|
45
|
+
: 'alert'}
|
|
46
|
+
size={14}
|
|
47
|
+
/>
|
|
48
|
+
<span class="chat-tool__action">{t(
|
|
49
|
+
'sandbox.chat.tool.action.' + action,
|
|
50
|
+
)}</span>
|
|
51
|
+
<span class="chat-tool__target" title={detail}>
|
|
52
|
+
{target || original.tool}
|
|
53
|
+
</span>
|
|
54
|
+
<span class="chat-tool__status">{t(
|
|
55
|
+
'sandbox.chat.tool.status.' + status,
|
|
56
|
+
)}</span>
|
|
57
|
+
@if (duration !== null) {
|
|
58
|
+
<span class="chat-tool__duration">{t('sandbox.chat.tool.duration', {
|
|
59
|
+
seconds: duration,
|
|
60
|
+
})}</span>
|
|
61
|
+
}
|
|
62
|
+
<Icon name="chevron-down" size={14} />
|
|
63
|
+
</summary>
|
|
64
|
+
<dl class="chat-tool__details">
|
|
65
|
+
<dt>{t('sandbox.chat.tool.name')}</dt>
|
|
66
|
+
<dd>{original.tool}</dd>
|
|
67
|
+
<dt>{t('sandbox.chat.tool.detail')}</dt>
|
|
68
|
+
<dd>{detail || t('sandbox.chat.tool.noDetail')}</dd>
|
|
69
|
+
@if (event.detail && event.detail !== detail) {
|
|
70
|
+
<>
|
|
71
|
+
<dt>{t('sandbox.chat.tool.result')}</dt>
|
|
72
|
+
<dd>{event.detail}</dd>
|
|
73
|
+
</>
|
|
74
|
+
}
|
|
75
|
+
</dl>
|
|
76
|
+
</details>
|
|
77
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useEffect, useState } from 'octane';
|
|
2
|
+
import { useTranslation } from './i18n.ts';
|
|
3
|
+
|
|
4
|
+
/* Mounted only while a turn runs. Age measures received updates, not provider
|
|
5
|
+
health: a quiet process can still be generating or waiting on the network. */
|
|
6
|
+
export function TurnActivity(props: {
|
|
7
|
+
readonly lastUpdateAt: number | undefined;
|
|
8
|
+
}) @{
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const [startedAt] = useState(Date.now());
|
|
11
|
+
const [now, setNow] = useState(Date.now());
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
14
|
+
return () => clearInterval(timer);
|
|
15
|
+
}, []);
|
|
16
|
+
const seconds = Math.max(
|
|
17
|
+
0,
|
|
18
|
+
Math.floor((now - (props.lastUpdateAt ?? startedAt)) / 1000),
|
|
19
|
+
);
|
|
20
|
+
<span class="chat__activity-age">{t(
|
|
21
|
+
seconds >= 30
|
|
22
|
+
? 'sandbox.chat.activity.quiet'
|
|
23
|
+
: 'sandbox.chat.activity.updated',
|
|
24
|
+
{ seconds },
|
|
25
|
+
)}</span>
|
|
26
|
+
}
|
|
@@ -257,6 +257,7 @@
|
|
|
257
257
|
"chat.role": "Role",
|
|
258
258
|
"chat.targetModule": "Target module",
|
|
259
259
|
"chat.auto": "Auto",
|
|
260
|
+
"chat.settings": "Settings",
|
|
260
261
|
"chat.autoHandoff": "Continue handoffs automatically",
|
|
261
262
|
"chat.stop": "Stop",
|
|
262
263
|
"chat.send": "Send",
|
|
@@ -480,8 +481,27 @@
|
|
|
480
481
|
"models.save": "Save",
|
|
481
482
|
"models.saved": "Model settings saved.",
|
|
482
483
|
"models.error": "Could not save model settings.",
|
|
484
|
+
"chat.activity.updated": "Last update {seconds}s ago",
|
|
485
|
+
"chat.activity.quiet": "No update for {seconds}s. The agent may still be working.",
|
|
483
486
|
"chat.activity.thinking": "Agent is thinking…",
|
|
484
487
|
"chat.activity.responding": "Agent is preparing a response…",
|
|
485
488
|
"chat.fresh": "Fresh agent context",
|
|
486
|
-
"chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved."
|
|
489
|
+
"chat.freshHelp": "The next message starts a fresh context with the brief and recent messages. Draft files, specification and sandbox history are preserved.",
|
|
490
|
+
"chat.tool.name": "Tool",
|
|
491
|
+
"chat.tool.detail": "Target or command",
|
|
492
|
+
"chat.tool.result": "Completion detail",
|
|
493
|
+
"chat.tool.noDetail": "No detail provided",
|
|
494
|
+
"chat.tool.duration": "{seconds}s",
|
|
495
|
+
"chat.tool.action.read": "Read file",
|
|
496
|
+
"chat.tool.action.edit": "Edit file",
|
|
497
|
+
"chat.tool.action.write": "Write file",
|
|
498
|
+
"chat.tool.action.delete": "Delete file",
|
|
499
|
+
"chat.tool.action.list": "Find files",
|
|
500
|
+
"chat.tool.action.search": "Search",
|
|
501
|
+
"chat.tool.action.command": "Run command",
|
|
502
|
+
"chat.tool.action.tool": "Tool call",
|
|
503
|
+
"chat.tool.status.done": "Done",
|
|
504
|
+
"chat.tool.status.failed": "Failed",
|
|
505
|
+
"chat.tool.status.running": "Running",
|
|
506
|
+
"chat.tool.status.unknown": "No result"
|
|
487
507
|
}
|
|
@@ -257,6 +257,7 @@
|
|
|
257
257
|
"chat.role": "Rola",
|
|
258
258
|
"chat.targetModule": "Moduł docelowy",
|
|
259
259
|
"chat.auto": "Automatycznie",
|
|
260
|
+
"chat.settings": "Ustawienia",
|
|
260
261
|
"chat.autoHandoff": "Automatycznie kontynuuj przekazania",
|
|
261
262
|
"chat.stop": "Zatrzymaj",
|
|
262
263
|
"chat.send": "Wyślij",
|
|
@@ -480,8 +481,27 @@
|
|
|
480
481
|
"models.save": "Zapisz",
|
|
481
482
|
"models.saved": "Zapisano ustawienia modeli.",
|
|
482
483
|
"models.error": "Nie udało się zapisać ustawień modeli.",
|
|
484
|
+
"chat.activity.updated": "Ostatnia aktualizacja {seconds}s temu",
|
|
485
|
+
"chat.activity.quiet": "Brak aktualizacji od {seconds}s. Agent może nadal pracować.",
|
|
483
486
|
"chat.activity.thinking": "Agent analizuje zadanie…",
|
|
484
487
|
"chat.activity.responding": "Agent przygotowuje odpowiedź…",
|
|
485
488
|
"chat.fresh": "Świeży kontekst agenta",
|
|
486
|
-
"chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane."
|
|
489
|
+
"chat.freshHelp": "Następna wiadomość rozpocznie nowy kontekst z briefem i ostatnimi wiadomościami. Pliki, specyfikacja i historia sandboxa pozostaną zachowane.",
|
|
490
|
+
"chat.tool.name": "Narzędzie",
|
|
491
|
+
"chat.tool.detail": "Plik lub polecenie",
|
|
492
|
+
"chat.tool.result": "Szczegóły zakończenia",
|
|
493
|
+
"chat.tool.noDetail": "Brak szczegółów",
|
|
494
|
+
"chat.tool.duration": "{seconds}s",
|
|
495
|
+
"chat.tool.action.read": "Odczyt pliku",
|
|
496
|
+
"chat.tool.action.edit": "Edycja pliku",
|
|
497
|
+
"chat.tool.action.write": "Zapis pliku",
|
|
498
|
+
"chat.tool.action.delete": "Usunięcie pliku",
|
|
499
|
+
"chat.tool.action.list": "Wyszukiwanie plików",
|
|
500
|
+
"chat.tool.action.search": "Wyszukiwanie",
|
|
501
|
+
"chat.tool.action.command": "Polecenie",
|
|
502
|
+
"chat.tool.action.tool": "Narzędzie",
|
|
503
|
+
"chat.tool.status.done": "Gotowe",
|
|
504
|
+
"chat.tool.status.failed": "Błąd",
|
|
505
|
+
"chat.tool.status.running": "W toku",
|
|
506
|
+
"chat.tool.status.unknown": "Brak wyniku"
|
|
487
507
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ChatEntry } from '../server/sessions.ts';
|
|
2
|
+
|
|
3
|
+
export interface TranscriptRow {
|
|
4
|
+
readonly key: number;
|
|
5
|
+
entry: ChatEntry;
|
|
6
|
+
started?: ChatEntry;
|
|
7
|
+
pending: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Pair calls by provider identity, including parallel calls to the same file.
|
|
11
|
+
Old transcripts without ids are paired only with one unambiguous pending call.
|
|
12
|
+
This is presentation only: persisted events and their full details stay intact. */
|
|
13
|
+
export function transcriptRows(entries: readonly ChatEntry[]): TranscriptRow[] {
|
|
14
|
+
const rows: TranscriptRow[] = [];
|
|
15
|
+
const calls = new Map<string, TranscriptRow>();
|
|
16
|
+
const pending = new Set<TranscriptRow>();
|
|
17
|
+
const closeTurn = () => {
|
|
18
|
+
for (const row of pending) row.pending = false;
|
|
19
|
+
pending.clear();
|
|
20
|
+
calls.clear();
|
|
21
|
+
};
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const event = entry.event;
|
|
24
|
+
if (
|
|
25
|
+
entry.kind === 'user' ||
|
|
26
|
+
entry.handoff ||
|
|
27
|
+
event?.type === 'turn.started' ||
|
|
28
|
+
event?.type === 'turn.completed'
|
|
29
|
+
)
|
|
30
|
+
closeTurn();
|
|
31
|
+
if (event?.type === 'reasoning' && !event.text.trim()) continue;
|
|
32
|
+
if (event?.type === 'tool.completed') {
|
|
33
|
+
let start = event.callId ? calls.get(event.callId) : undefined;
|
|
34
|
+
if (!event.callId && pending.size === 1) {
|
|
35
|
+
const candidate = pending.values().next().value!;
|
|
36
|
+
const original = candidate.entry.event;
|
|
37
|
+
if (
|
|
38
|
+
original?.type === 'tool.started' &&
|
|
39
|
+
!original.callId &&
|
|
40
|
+
(event.tool === original.tool || event.tool === 'tool') &&
|
|
41
|
+
(!event.detail || event.detail === original.detail)
|
|
42
|
+
)
|
|
43
|
+
start = candidate;
|
|
44
|
+
}
|
|
45
|
+
if (
|
|
46
|
+
start &&
|
|
47
|
+
start.entry.role === entry.role &&
|
|
48
|
+
start.entry.module === entry.module
|
|
49
|
+
) {
|
|
50
|
+
start.started = start.entry;
|
|
51
|
+
start.entry = entry;
|
|
52
|
+
start.pending = false;
|
|
53
|
+
pending.delete(start);
|
|
54
|
+
if (event.callId) calls.delete(event.callId);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const row: TranscriptRow = {
|
|
59
|
+
key: entry.sequence,
|
|
60
|
+
entry,
|
|
61
|
+
pending: event?.type === 'tool.started',
|
|
62
|
+
};
|
|
63
|
+
rows.push(row);
|
|
64
|
+
if (event?.type === 'tool.started') {
|
|
65
|
+
pending.add(row);
|
|
66
|
+
if (event.callId) calls.set(event.callId, row);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return rows;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function shortToolDetail(detail: string): string {
|
|
73
|
+
if (!/^(?:\/|[A-Za-z]:[\\/]|modules[\\/])/.test(detail)) return detail;
|
|
74
|
+
return detail
|
|
75
|
+
.replaceAll('\\', '/')
|
|
76
|
+
.replace(
|
|
77
|
+
/^.*?\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/[^/]+\/workspace\//,
|
|
78
|
+
'',
|
|
79
|
+
)
|
|
80
|
+
.replace(/^modules\//, '');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function toolAction(name: string): string {
|
|
84
|
+
switch (name.toLowerCase()) {
|
|
85
|
+
case 'read':
|
|
86
|
+
case 'read_file':
|
|
87
|
+
return 'read';
|
|
88
|
+
case 'edit':
|
|
89
|
+
case 'multiedit':
|
|
90
|
+
case 'update':
|
|
91
|
+
return 'edit';
|
|
92
|
+
case 'write':
|
|
93
|
+
case 'write_file':
|
|
94
|
+
case 'create':
|
|
95
|
+
return 'write';
|
|
96
|
+
case 'delete_file':
|
|
97
|
+
case 'delete':
|
|
98
|
+
return 'delete';
|
|
99
|
+
case 'glob':
|
|
100
|
+
case 'list_files':
|
|
101
|
+
return 'list';
|
|
102
|
+
case 'grep':
|
|
103
|
+
return 'search';
|
|
104
|
+
case 'bash':
|
|
105
|
+
case 'command':
|
|
106
|
+
return 'command';
|
|
107
|
+
default:
|
|
108
|
+
return 'tool';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
writeFile,
|
|
10
10
|
} from 'node:fs/promises';
|
|
11
11
|
import { basename, join } from 'node:path';
|
|
12
|
+
import { referenceSource } from './reference.ts';
|
|
12
13
|
import {
|
|
13
14
|
basePathOf,
|
|
14
15
|
modulePathOf,
|
|
@@ -107,7 +108,7 @@ export async function prepareAutoReview(
|
|
|
107
108
|
const skill = join(paths.workspace, 'reference', 'skills', 'auto-review');
|
|
108
109
|
await mkdir(skill, { recursive: true });
|
|
109
110
|
await cp(
|
|
110
|
-
|
|
111
|
+
await referenceSource(workspaceRoot, '.ai/skills/auto-review/SKILL.md'),
|
|
111
112
|
join(skill, 'SKILL.md'),
|
|
112
113
|
);
|
|
113
114
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { matchesGlob } from 'node:path';
|
|
2
|
+
import type { AgentRoleDefinition } from '#coding-agent';
|
|
3
|
+
import type { GateResult } from './gates.ts';
|
|
4
|
+
|
|
5
|
+
interface DiagnosticPath {
|
|
6
|
+
path: string;
|
|
7
|
+
module: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Read diagnostic locations, never instructions embedded in output. Manifest
|
|
11
|
+
reports name the module; their issues name the file that actually failed. */
|
|
12
|
+
function locations(gate: GateResult, activeModule: string): DiagnosticPath[] {
|
|
13
|
+
const module = gate.module ?? activeModule;
|
|
14
|
+
const output = gate.output.slice(0, 16_000);
|
|
15
|
+
try {
|
|
16
|
+
const result = JSON.parse(
|
|
17
|
+
output.slice(output.indexOf('{'), output.lastIndexOf('}') + 1),
|
|
18
|
+
);
|
|
19
|
+
const reports = result?.error?.details?.reports;
|
|
20
|
+
if (Array.isArray(reports)) {
|
|
21
|
+
return reports.flatMap((report) => {
|
|
22
|
+
const target =
|
|
23
|
+
typeof report.file === 'string'
|
|
24
|
+
? (/(?:^|\/)modules\/([a-z0-9-]+)\/module\.json$/.exec(
|
|
25
|
+
report.file,
|
|
26
|
+
)?.[1] ?? module)
|
|
27
|
+
: module;
|
|
28
|
+
return Array.isArray(report.issues)
|
|
29
|
+
? report.issues
|
|
30
|
+
.filter(
|
|
31
|
+
(issue: { severity?: string; path?: unknown }) =>
|
|
32
|
+
issue.severity === 'error' && typeof issue.path === 'string',
|
|
33
|
+
)
|
|
34
|
+
.map((issue: { path: string }) => ({
|
|
35
|
+
path: issue.path,
|
|
36
|
+
module: target,
|
|
37
|
+
}))
|
|
38
|
+
: [];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
/* Compiler and formatter output is plain text. */
|
|
43
|
+
}
|
|
44
|
+
return output.split('\n').flatMap((line) => {
|
|
45
|
+
const match =
|
|
46
|
+
/^\s*(?:FAIL\s+)?((?:modules\/[a-z0-9-]+\/)?(?:src|tests|translations|migrations)\/[^\s:(]+)(?:\(\d+,\d+\)|:\d+|\s|$)/.exec(
|
|
47
|
+
line,
|
|
48
|
+
);
|
|
49
|
+
if (!match) return [];
|
|
50
|
+
const qualified = /^modules\/([a-z0-9-]+)\/(.+)$/.exec(match[1]!);
|
|
51
|
+
return [
|
|
52
|
+
{ path: qualified?.[2] ?? match[1]!, module: qualified?.[1] ?? module },
|
|
53
|
+
];
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function gateRepairOwner(
|
|
58
|
+
gate: GateResult,
|
|
59
|
+
activeModule: string,
|
|
60
|
+
roles: readonly AgentRoleDefinition[],
|
|
61
|
+
modules: readonly string[],
|
|
62
|
+
): { role: string; module: string } | null {
|
|
63
|
+
for (const location of locations(gate, activeModule)) {
|
|
64
|
+
if (
|
|
65
|
+
!modules.includes(location.module) ||
|
|
66
|
+
location.path.split('/').includes('..')
|
|
67
|
+
)
|
|
68
|
+
continue;
|
|
69
|
+
const preferred =
|
|
70
|
+
location.path.startsWith('src/client/') ||
|
|
71
|
+
location.path.startsWith('translations/')
|
|
72
|
+
? 'frontend-engineer'
|
|
73
|
+
: location.path.startsWith('spec/')
|
|
74
|
+
? 'business-manager'
|
|
75
|
+
: location.path.startsWith('src/agent/') ||
|
|
76
|
+
location.path.startsWith('src/tools/')
|
|
77
|
+
? 'agentic-engineer'
|
|
78
|
+
: 'backend-engineer';
|
|
79
|
+
const candidates = roles.filter((role) =>
|
|
80
|
+
role.allowedPaths.some((pattern) => matchesGlob(location.path, pattern)),
|
|
81
|
+
);
|
|
82
|
+
const owner =
|
|
83
|
+
candidates.find((role) => role.id === preferred) ??
|
|
84
|
+
(candidates.length === 1 ? candidates[0] : undefined);
|
|
85
|
+
if (owner) return { role: owner.id, module: location.module };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
package/src/server/planning.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
HandoffDeclaration,
|
|
9
9
|
} from '#coding-agent';
|
|
10
10
|
import type { GateResult } from './gates.ts';
|
|
11
|
+
import { gateRepairOwner } from './gate-repair.ts';
|
|
11
12
|
import { SandboxSetupError } from './workspace-root.ts';
|
|
12
13
|
import {
|
|
13
14
|
moduleSuffixOf,
|
|
@@ -573,20 +574,29 @@ export function planHandoff(context: HandoffContext): HandoffPlan {
|
|
|
573
574
|
there even when the finished turn worked somewhere else. */
|
|
574
575
|
const failedGate = context.gates.find((gate) => gate.status !== 'passed');
|
|
575
576
|
if (failedGate) {
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
577
|
+
const owner = gateRepairOwner(
|
|
578
|
+
failedGate,
|
|
579
|
+
context.module,
|
|
580
|
+
roles,
|
|
581
|
+
context.routing.session.modules.map((module) => module.directory),
|
|
582
|
+
);
|
|
583
|
+
const repairRole =
|
|
584
|
+
owner?.role ??
|
|
585
|
+
(context.reviewing
|
|
586
|
+
? (validateDeclared(context).role ?? context.role)
|
|
587
|
+
: context.role);
|
|
579
588
|
return plan(
|
|
580
589
|
'continue',
|
|
581
590
|
repairRole,
|
|
582
591
|
`The ${gateLabel(failedGate)} gate failed, so the responsible specialist fixes it before delivery.`,
|
|
583
592
|
[
|
|
584
|
-
`The ${gateLabel(failedGate)} gate failed
|
|
593
|
+
`Continue as ${roleName(roles, repairRole)}. The ${gateLabel(failedGate)} gate failed. Fix the reported files within your role, preserve other work, and end with your handoff line.`,
|
|
594
|
+
`Recorded gate results:\n${context.gates.map((gate) => `${gateLabel(gate)}: ${gate.status}`).join('\n')}`,
|
|
585
595
|
`Gate command: ${failedGate.command}`,
|
|
586
596
|
`Gate output (first ${GATE_PROMPT_OUTPUT} characters; the transcript holds the rest):`,
|
|
587
597
|
failedGate.output.slice(0, GATE_PROMPT_OUTPUT),
|
|
588
598
|
].join('\n\n'),
|
|
589
|
-
failedGate.module ?? context.module,
|
|
599
|
+
owner?.module ?? failedGate.module ?? context.module,
|
|
590
600
|
);
|
|
591
601
|
}
|
|
592
602
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import type { Plugin } from 'vite';
|
|
3
|
+
|
|
4
|
+
/* Octane broadcasts full-reload for every TSRX change, including modules used
|
|
5
|
+
only by preview iframes. Draft saves must invalidate cached transforms without
|
|
6
|
+
navigating the operator's page. The preview refreshes at turn completion or
|
|
7
|
+
on request, when its isolated worker also selects the current revision. */
|
|
8
|
+
export function isolatePreviewHotUpdates(
|
|
9
|
+
plugins: Plugin[],
|
|
10
|
+
workspaceRoot: string,
|
|
11
|
+
): Plugin[] {
|
|
12
|
+
return plugins.map((plugin) => {
|
|
13
|
+
const hook = plugin.hotUpdate;
|
|
14
|
+
if (!hook) return plugin;
|
|
15
|
+
const original = typeof hook === 'function' ? hook : hook.handler;
|
|
16
|
+
return {
|
|
17
|
+
...plugin,
|
|
18
|
+
hotUpdate: {
|
|
19
|
+
...(typeof hook === 'function' ? {} : hook),
|
|
20
|
+
async handler(options) {
|
|
21
|
+
const path = relative(workspaceRoot, options.file).replaceAll(
|
|
22
|
+
'\\',
|
|
23
|
+
'/',
|
|
24
|
+
);
|
|
25
|
+
if (/^(?:\.flowdular|\.coreloom)\/sandbox\/sessions\//.test(path)) {
|
|
26
|
+
for (const environment of Object.values(
|
|
27
|
+
options.server.environments,
|
|
28
|
+
)) {
|
|
29
|
+
const graph = environment.moduleGraph;
|
|
30
|
+
for (const module of graph.getModulesByFile(options.file) ?? [])
|
|
31
|
+
graph.invalidateModule(module);
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return original.call(this, options);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/server/reference.ts
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import type { ViteDevServer } from 'vite';
|
|
3
|
+
|
|
4
|
+
/* Watch notifications describe source changes, not successful preview builds.
|
|
5
|
+
Collect one bounded burst so atomic saves do not flood the terminal. */
|
|
6
|
+
export function watchSandboxReloads(
|
|
7
|
+
server: Pick<ViteDevServer, 'watcher' | 'httpServer'>,
|
|
8
|
+
appRoot: string,
|
|
9
|
+
write: (message: string) => void,
|
|
10
|
+
verbose = false,
|
|
11
|
+
): () => void {
|
|
12
|
+
const groups = new Map<string, Set<string>>();
|
|
13
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
14
|
+
let pendingFiles = 0;
|
|
15
|
+
const flush = () => {
|
|
16
|
+
if (timer) clearTimeout(timer);
|
|
17
|
+
timer = undefined;
|
|
18
|
+
for (const [label, files] of groups) {
|
|
19
|
+
if (verbose) {
|
|
20
|
+
for (const path of files) write(path);
|
|
21
|
+
} else {
|
|
22
|
+
write(
|
|
23
|
+
`${label} · ${files.size} ${files.size === 1 ? 'file' : 'files'} changed`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
groups.clear();
|
|
28
|
+
pendingFiles = 0;
|
|
29
|
+
};
|
|
30
|
+
const changed = (event: string, path: string) => {
|
|
31
|
+
if (!['add', 'change', 'unlink'].includes(event)) return;
|
|
32
|
+
const normalized = path.replaceAll('\\', '/');
|
|
33
|
+
const draft = normalized.match(
|
|
34
|
+
/\/(?:\.flowdular|\.coreloom)\/sandbox\/sessions\/([^/]+)\/workspace\/modules\/([^/]+)\//,
|
|
35
|
+
);
|
|
36
|
+
const local = relative(appRoot, path);
|
|
37
|
+
if (
|
|
38
|
+
!draft &&
|
|
39
|
+
(local === '..' || local.startsWith('../') || local.startsWith('..\\'))
|
|
40
|
+
)
|
|
41
|
+
return;
|
|
42
|
+
const label = draft
|
|
43
|
+
? `Draft ${draft[2]} (${draft[1]!.slice(0, 8)})`
|
|
44
|
+
: 'Sandbox';
|
|
45
|
+
const files = groups.get(label) ?? new Set<string>();
|
|
46
|
+
if (!files.has(path)) pendingFiles += 1;
|
|
47
|
+
files.add(path);
|
|
48
|
+
groups.set(label, files);
|
|
49
|
+
// Fixed window, not a reset-on-every-event debounce: continuous writes
|
|
50
|
+
// remain visible and retained notifications cannot grow without a flush.
|
|
51
|
+
if (!timer) {
|
|
52
|
+
timer = setTimeout(flush, 750);
|
|
53
|
+
timer.unref?.();
|
|
54
|
+
}
|
|
55
|
+
if (pendingFiles >= 256) flush();
|
|
56
|
+
};
|
|
57
|
+
const dispose = () => {
|
|
58
|
+
if (timer) clearTimeout(timer);
|
|
59
|
+
timer = undefined;
|
|
60
|
+
groups.clear();
|
|
61
|
+
pendingFiles = 0;
|
|
62
|
+
server.watcher.off('all', changed);
|
|
63
|
+
server.httpServer?.off('close', dispose);
|
|
64
|
+
};
|
|
65
|
+
server.watcher.on('all', changed);
|
|
66
|
+
server.httpServer?.once('close', dispose);
|
|
67
|
+
return dispose;
|
|
68
|
+
}
|