@try-works/dsh-recursive-mode 0.2.2 → 0.2.4
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/lib/client/board.d.ts +7 -1
- package/lib/client/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +2 -0
- package/lib/client/slots.d.ts +10 -0
- package/lib/client.js +875 -65
- package/lib/index.js +142 -59
- package/lib/live-route.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/board.tsx +14 -7
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +2 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/slots.ts +43 -0
- package/src/client/styles.ts +321 -0
- package/src/live-route.ts +69 -2
package/src/client/inspector.tsx
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* toggle, and solid status pills. All from the live host-route snapshot.
|
|
5
5
|
* useBoardTheme is hoisted ABOVE the not-found early return.
|
|
6
6
|
*/
|
|
7
|
-
import { createElement, type ReactNode } from 'react'
|
|
7
|
+
import { createElement, useState, type ReactNode } from 'react'
|
|
8
8
|
import type { LiveProjectionValue } from './contract.ts'
|
|
9
9
|
import { cardFacts, expandPhaseRows, cardPill, phaseStatusPill, PILL_LABELS } from './derive.ts'
|
|
10
|
+
import { DocViewer } from './doc-viewer.tsx'
|
|
10
11
|
import { useBoardTheme, ThemeToggle } from './theme.ts'
|
|
11
12
|
import type { BoardTheme } from './theme.ts'
|
|
12
13
|
|
|
@@ -44,6 +45,7 @@ function detailShell(runId: string, onBackToToolDetails: () => void, onClose: ((
|
|
|
44
45
|
|
|
45
46
|
export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails, onClose }: InspectorProps) {
|
|
46
47
|
const { theme, toggle } = useBoardTheme()
|
|
48
|
+
const [openFileName, setOpenFileName] = useState<string | null>(null)
|
|
47
49
|
const card = snapshot?.projection?.[worktreeRoot]?.[runId]
|
|
48
50
|
if (card === undefined) {
|
|
49
51
|
return detailShell(runId, onBackToToolDetails, onClose, null,
|
|
@@ -55,13 +57,22 @@ export function Inspector({ runId, worktreeRoot, snapshot, onBackToToolDetails,
|
|
|
55
57
|
const rows = expandPhaseRows(card)
|
|
56
58
|
const pill = cardPill(card)
|
|
57
59
|
const headerExtra = solidPill(pill)
|
|
58
|
-
const body =
|
|
60
|
+
const body = openFileName !== null
|
|
61
|
+
? createElement(DocViewer, { runId, worktreeRoot, fileName: openFileName, theme, onClose: () => setOpenFileName(null) })
|
|
62
|
+
: createElement('section', { className: 'rec-detail-section' },
|
|
59
63
|
createElement('h3', {}, 'Phases'),
|
|
60
64
|
rows.map((row) => createElement('div', { key: row.phase, className: 'rec-phase-row' },
|
|
61
65
|
createElement('span', { className: 'rec-phase-id' }, row.phase),
|
|
62
66
|
createElement('span', { className: 'rec-phase-name' }, row.fileName ?? '—'),
|
|
63
67
|
solidPill(phaseStatusPill(row.status)),
|
|
64
68
|
row.lockHash !== undefined && createElement('code', { className: 'rec-lockhash' }, '#' + row.lockHash.slice(0, 8)),
|
|
69
|
+
row.present && row.fileName !== null && createElement('button', {
|
|
70
|
+
type: 'button',
|
|
71
|
+
className: 'rec-phase-view',
|
|
72
|
+
onClick: () => setOpenFileName(row.fileName!),
|
|
73
|
+
title: 'View phase doc',
|
|
74
|
+
'aria-label': 'View phase doc',
|
|
75
|
+
}, 'View phase'),
|
|
65
76
|
)),
|
|
66
77
|
facts.gateBlocked && createElement('div', { className: 'rec-detail-section' },
|
|
67
78
|
createElement('h3', {}, 'Gate'),
|
package/src/client/slots.ts
CHANGED
|
@@ -64,11 +64,54 @@ export function RecursiveLauncherGate({ useSessions }: { useSessions: SnapshotSe
|
|
|
64
64
|
return createElement('button', { className: 'rec-launcher', title: 'Recursive runs', onClick: () => boardState.openBoard() }, '⧉')
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* RecursiveView: the conversation.view tab body. Renders the run board INLINE
|
|
69
|
+
* (fills the view area) keyed on the CURRENT workspace, and swaps to the
|
|
70
|
+
* inspector modal when a run is opened. Read-only (R9): uses the live host
|
|
71
|
+
* route. No preset gate — the tab is discoverable in any session.
|
|
72
|
+
*/
|
|
73
|
+
export function RecursiveView({ useSessions, useWorkspaces }: { useSessions: SnapshotSelectorHook<SessionListStateLike>; useWorkspaces: SnapshotSelectorHook<WorkspaceListStateLike> }): ReactNode {
|
|
74
|
+
const board = useBoardState()
|
|
75
|
+
const sessions = useRecursiveSessions(useSessions)
|
|
76
|
+
const workspaces: WorkspaceListStateLike = useWorkspaces((s) => s) ?? { items: [], recentWorkspaceId: undefined }
|
|
77
|
+
const wsPath = currentWorkspacePath(workspaces, sessions)
|
|
78
|
+
const scope = { cwd: wsPath }
|
|
79
|
+
const snapshot = useLiveProjection(scope)
|
|
80
|
+
// Inspector drill-down (modal over the inline board) when a run is selected.
|
|
81
|
+
if (board.selection !== null) {
|
|
82
|
+
return createElement(Inspector, {
|
|
83
|
+
runId: board.selection.runId,
|
|
84
|
+
worktreeRoot: board.selection.worktreeRoot,
|
|
85
|
+
snapshot,
|
|
86
|
+
onBackToToolDetails: () => boardState.backToBoard(),
|
|
87
|
+
onClose: () => boardState.close(),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
return createElement(Board, { snapshot, workspacePath: wsPath, variant: 'view', onOpenInspector: (sel) => boardState.openInspector(sel) })
|
|
91
|
+
}
|
|
92
|
+
|
|
67
93
|
export function registerSlots(ctx: ClientContext): () => void {
|
|
68
94
|
const disposers: (() => void)[] = []
|
|
69
95
|
// Run 15: inject the one-shot theme-token stylesheet once per document (idempotent).
|
|
70
96
|
disposers.push(injectBoardStyles())
|
|
71
97
|
|
|
98
|
+
// Conversation view tab (Chat | Trajectory | Recursive): the recursive run
|
|
99
|
+
// board as a first-class tab in the conversation header, rendered INLINE in
|
|
100
|
+
// the view area (not a fixed overlay). Order 20 places it to the RIGHT of
|
|
101
|
+
// Trajectory (order 10). Always present — no recursive-preset gate, so the
|
|
102
|
+
// entry point is discoverable in any session.
|
|
103
|
+
disposers.push(ctx.slots.inject('conversation.view', () => ctx.slots.register({
|
|
104
|
+
name: 'conversation.view',
|
|
105
|
+
id: 'recursive',
|
|
106
|
+
order: 20,
|
|
107
|
+
label: 'Recursive',
|
|
108
|
+
}, (props: RootSlotProps) => {
|
|
109
|
+
const useSessions = props?.useSessions
|
|
110
|
+
const useWorkspaces = props?.useWorkspaces
|
|
111
|
+
if (useSessions === undefined || useWorkspaces === undefined) return null
|
|
112
|
+
return createElement(RecursiveView, { useSessions, useWorkspaces })
|
|
113
|
+
})))
|
|
114
|
+
|
|
72
115
|
// Board launcher in the sidebar footer action list — OPENS the shared board store (run 08 R1).
|
|
73
116
|
// Run 11 (UX gate): the seat is root-scoped (visible in every session), but the board it
|
|
74
117
|
// opens is recursive-preset-gated. In a code/other session the icon was a dead click — a
|
package/src/client/styles.ts
CHANGED
|
@@ -136,6 +136,17 @@ const BOARD_CSS = `
|
|
|
136
136
|
overflow: hidden;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/* Inline board (conversation.view tab): fills its parent view area, not fixed. */
|
|
140
|
+
.rec-board-view {
|
|
141
|
+
position: relative;
|
|
142
|
+
inset: auto;
|
|
143
|
+
z-index: auto;
|
|
144
|
+
width: 100%;
|
|
145
|
+
height: 100%;
|
|
146
|
+
min-height: 0;
|
|
147
|
+
flex: 1;
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
.rec-board-header {
|
|
140
151
|
display: flex;
|
|
141
152
|
align-items: center;
|
|
@@ -521,6 +532,316 @@ const BOARD_CSS = `
|
|
|
521
532
|
color: var(--board-muted-fg);
|
|
522
533
|
}
|
|
523
534
|
|
|
535
|
+
/* ===== Per-phase doc viewer (0.2.4, inside the inspector detail body) ===== */
|
|
536
|
+
|
|
537
|
+
/* View phase ghost button on each present phase row. */
|
|
538
|
+
.rec-phase-view {
|
|
539
|
+
flex: none;
|
|
540
|
+
padding: 3px 10px;
|
|
541
|
+
font-size: var(--board-text-xs);
|
|
542
|
+
color: var(--board-info);
|
|
543
|
+
background: transparent;
|
|
544
|
+
border: 1px solid var(--board-border);
|
|
545
|
+
border-radius: 999px;
|
|
546
|
+
cursor: pointer;
|
|
547
|
+
white-space: nowrap;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.rec-phase-view:hover {
|
|
551
|
+
background: var(--board-accent);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* Viewer shell: fills the inspector detail body (the panel owns data-theme). */
|
|
555
|
+
.rec-doc {
|
|
556
|
+
display: flex;
|
|
557
|
+
flex-direction: column;
|
|
558
|
+
min-height: 0;
|
|
559
|
+
gap: var(--board-space-12);
|
|
560
|
+
color: var(--board-fg);
|
|
561
|
+
font-family: var(--board-font-sans);
|
|
562
|
+
background: var(--board-bg);
|
|
563
|
+
border: 1px solid var(--board-border);
|
|
564
|
+
border-radius: var(--board-radius-xl);
|
|
565
|
+
overflow: hidden;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.rec-doc-header {
|
|
569
|
+
display: flex;
|
|
570
|
+
align-items: center;
|
|
571
|
+
gap: var(--board-space-12);
|
|
572
|
+
padding: var(--board-space-12) var(--board-space-16);
|
|
573
|
+
border-bottom: 1px solid var(--board-border);
|
|
574
|
+
flex: none;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.rec-doc-heading {
|
|
578
|
+
display: flex;
|
|
579
|
+
align-items: baseline;
|
|
580
|
+
gap: var(--board-space-12);
|
|
581
|
+
flex: 1;
|
|
582
|
+
min-width: 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
.rec-doc-badge {
|
|
586
|
+
flex: none;
|
|
587
|
+
padding: 2px 10px;
|
|
588
|
+
font-size: 11px;
|
|
589
|
+
font-weight: var(--board-fw-semibold);
|
|
590
|
+
letter-spacing: 0.05em;
|
|
591
|
+
text-transform: uppercase;
|
|
592
|
+
border-radius: 999px;
|
|
593
|
+
background: var(--board-accent);
|
|
594
|
+
color: var(--board-muted-fg);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.rec-doc-title {
|
|
598
|
+
margin: 0;
|
|
599
|
+
font-size: var(--board-text-sm);
|
|
600
|
+
font-weight: var(--board-fw-semibold);
|
|
601
|
+
letter-spacing: var(--board-tracking-tight);
|
|
602
|
+
overflow: hidden;
|
|
603
|
+
text-overflow: ellipsis;
|
|
604
|
+
white-space: nowrap;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.rec-doc-run {
|
|
608
|
+
flex: none;
|
|
609
|
+
font-family: var(--board-font-mono);
|
|
610
|
+
font-size: var(--board-text-xs);
|
|
611
|
+
color: var(--board-muted-fg);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
.rec-doc-btn {
|
|
615
|
+
flex: none;
|
|
616
|
+
padding: 5px 12px;
|
|
617
|
+
font-size: var(--board-text-xs);
|
|
618
|
+
color: var(--board-fg);
|
|
619
|
+
background: transparent;
|
|
620
|
+
border: 1px solid var(--board-border);
|
|
621
|
+
border-radius: 999px;
|
|
622
|
+
cursor: pointer;
|
|
623
|
+
white-space: nowrap;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.rec-doc-btn:hover {
|
|
627
|
+
background: var(--board-accent);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/* Search bar (/ to open). */
|
|
631
|
+
.rec-doc-search {
|
|
632
|
+
display: flex;
|
|
633
|
+
align-items: center;
|
|
634
|
+
gap: var(--board-space-12);
|
|
635
|
+
padding: var(--board-space-8) var(--board-space-16);
|
|
636
|
+
border-bottom: 1px solid var(--board-border);
|
|
637
|
+
flex: none;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.rec-doc-search-input {
|
|
641
|
+
flex: 1 1 auto;
|
|
642
|
+
background: var(--board-muted);
|
|
643
|
+
color: var(--board-fg);
|
|
644
|
+
border: 1px solid var(--board-border);
|
|
645
|
+
border-radius: var(--board-radius-md);
|
|
646
|
+
padding: 5px 10px;
|
|
647
|
+
font-size: var(--board-text-xs);
|
|
648
|
+
font-family: inherit;
|
|
649
|
+
min-width: 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.rec-doc-search-input::placeholder {
|
|
653
|
+
color: var(--board-muted-fg);
|
|
654
|
+
opacity: 1;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
.rec-doc-search-count {
|
|
658
|
+
font-size: var(--board-text-xs);
|
|
659
|
+
color: var(--board-muted-fg);
|
|
660
|
+
white-space: nowrap;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* Body: scrollable line list. */
|
|
664
|
+
.rec-doc-body {
|
|
665
|
+
flex: 1 1 auto;
|
|
666
|
+
min-height: 0;
|
|
667
|
+
overflow-y: auto;
|
|
668
|
+
padding: var(--board-space-12) var(--board-space-16) var(--board-space-16);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.rec-doc-line {
|
|
672
|
+
font-size: var(--board-text-sm);
|
|
673
|
+
line-height: 1.7;
|
|
674
|
+
padding: 0 6px;
|
|
675
|
+
border-left: 2px solid transparent;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.rec-doc-line-current {
|
|
679
|
+
background: var(--board-muted);
|
|
680
|
+
border-left-color: var(--board-info);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.rec-doc-blank {
|
|
684
|
+
height: 10px;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.rec-doc-plain {
|
|
688
|
+
white-space: pre-wrap;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.rec-doc-h1 {
|
|
692
|
+
font-size: 22px;
|
|
693
|
+
font-weight: 700;
|
|
694
|
+
margin: 8px 0 6px;
|
|
695
|
+
line-height: 1.3;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.rec-doc-h2 {
|
|
699
|
+
font-size: 18px;
|
|
700
|
+
font-weight: 600;
|
|
701
|
+
margin: 12px 0 4px;
|
|
702
|
+
line-height: 1.3;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.rec-doc-h3 {
|
|
706
|
+
font-size: 15px;
|
|
707
|
+
font-weight: 600;
|
|
708
|
+
margin: 10px 0 4px;
|
|
709
|
+
line-height: 1.3;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.rec-doc-h4 {
|
|
713
|
+
font-size: 14px;
|
|
714
|
+
font-weight: 600;
|
|
715
|
+
margin: 8px 0 4px;
|
|
716
|
+
line-height: 1.3;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.rec-doc-li {
|
|
720
|
+
display: flex;
|
|
721
|
+
align-items: baseline;
|
|
722
|
+
gap: 8px;
|
|
723
|
+
font-size: var(--board-text-sm);
|
|
724
|
+
line-height: 1.65;
|
|
725
|
+
margin: 0 0 4px;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.rec-doc-bullet {
|
|
729
|
+
flex: none;
|
|
730
|
+
color: var(--board-muted-fg);
|
|
731
|
+
width: 14px;
|
|
732
|
+
text-align: center;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.rec-doc-li-text {
|
|
736
|
+
flex: 1;
|
|
737
|
+
min-width: 0;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.rec-doc-pre {
|
|
741
|
+
margin: 6px 0;
|
|
742
|
+
padding: var(--board-space-12);
|
|
743
|
+
background: var(--board-muted);
|
|
744
|
+
border: 1px solid var(--board-border);
|
|
745
|
+
border-radius: var(--board-radius-md);
|
|
746
|
+
overflow-x: auto;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.rec-doc-code,
|
|
750
|
+
.rec-doc-inline-code {
|
|
751
|
+
font-family: var(--board-font-mono);
|
|
752
|
+
font-size: 12.5px;
|
|
753
|
+
letter-spacing: var(--board-tracking-mono);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.rec-doc-inline-code {
|
|
757
|
+
background: var(--board-muted);
|
|
758
|
+
border: 1px solid var(--board-border);
|
|
759
|
+
border-radius: 4px;
|
|
760
|
+
padding: 0 4px;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.rec-doc-inline-link {
|
|
764
|
+
color: var(--board-info);
|
|
765
|
+
text-decoration: underline;
|
|
766
|
+
cursor: pointer;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.rec-doc-table {
|
|
770
|
+
width: 100%;
|
|
771
|
+
border-collapse: collapse;
|
|
772
|
+
margin: 6px 0;
|
|
773
|
+
font-size: var(--board-text-xs);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.rec-doc-table th,
|
|
777
|
+
.rec-doc-table td {
|
|
778
|
+
border: 1px solid var(--board-border);
|
|
779
|
+
padding: 4px 8px;
|
|
780
|
+
text-align: left;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
.rec-doc-table th {
|
|
784
|
+
background: var(--board-muted);
|
|
785
|
+
color: var(--board-muted-fg);
|
|
786
|
+
font-weight: var(--board-fw-semibold);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/* Footer: status + key hints. */
|
|
790
|
+
.rec-doc-footer {
|
|
791
|
+
flex: none;
|
|
792
|
+
display: flex;
|
|
793
|
+
align-items: center;
|
|
794
|
+
gap: var(--board-space-16);
|
|
795
|
+
padding: var(--board-space-8) var(--board-space-16);
|
|
796
|
+
border-top: 1px solid var(--board-border);
|
|
797
|
+
flex-wrap: wrap;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.rec-doc-status {
|
|
801
|
+
font-size: var(--board-text-xs);
|
|
802
|
+
color: var(--board-muted-fg);
|
|
803
|
+
min-width: 120px;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
.rec-doc-status-error {
|
|
807
|
+
color: var(--board-error);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.rec-doc-status-ok {
|
|
811
|
+
color: var(--board-success);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
.rec-doc-hints {
|
|
815
|
+
display: flex;
|
|
816
|
+
align-items: center;
|
|
817
|
+
gap: 2px;
|
|
818
|
+
flex-wrap: wrap;
|
|
819
|
+
font-size: var(--board-text-xs);
|
|
820
|
+
color: var(--board-muted-fg);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.rec-doc-hints kbd {
|
|
824
|
+
background: var(--board-muted);
|
|
825
|
+
border: 1px solid var(--board-border);
|
|
826
|
+
border-radius: 4px;
|
|
827
|
+
padding: 1px 6px;
|
|
828
|
+
font-family: inherit;
|
|
829
|
+
font-size: 11px;
|
|
830
|
+
font-weight: 600;
|
|
831
|
+
color: var(--board-fg);
|
|
832
|
+
margin: 0 3px 0 8px;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.rec-doc-text {
|
|
836
|
+
margin: 0;
|
|
837
|
+
font-size: var(--board-text-sm);
|
|
838
|
+
color: var(--board-muted-fg);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
.rec-doc-error {
|
|
842
|
+
color: var(--board-error);
|
|
843
|
+
}
|
|
844
|
+
|
|
524
845
|
/* ===== Strip (session dock) — keeps the shell --dsw-* theme ===== */
|
|
525
846
|
.rec-badge {
|
|
526
847
|
flex: none;
|
package/src/live-route.ts
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
* would be both wrong and a crash).
|
|
18
18
|
*/
|
|
19
19
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { join, resolve, sep } from 'node:path'
|
|
20
22
|
import type { RecursiveProjection } from './types.ts'
|
|
23
|
+
import { RUN_ARTIFACT_SEQUENCE } from './status.ts'
|
|
21
24
|
|
|
22
25
|
/** API prefix the board/strip fetch. */
|
|
23
26
|
export const RECURSIVE_API_PREFIX = '/.recursive/api'
|
|
@@ -65,8 +68,72 @@ function queryOf(req: IncomingMessage, name: string): string {
|
|
|
65
68
|
return new URLSearchParams(url.slice(q + 1)).get(name) ?? ''
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/** Phase-doc basename allowlist (the run artifact sequence: single *.md, no subdirs). */
|
|
72
|
+
const PHASE_DOC_FILES = new Set(RUN_ARTIFACT_SEQUENCE)
|
|
73
|
+
|
|
74
|
+
/** runId/file safety: alphanumerics, dot, underscore, dash only; no '..', no separators. */
|
|
75
|
+
const DOC_SAFE_RE = /^[A-Za-z0-9._-]+$/
|
|
76
|
+
|
|
77
|
+
/** Invalid runId or file name (path traversal / subdir / non-phase doc) — reject. */
|
|
78
|
+
function docTargetError(runId: string, file: string): string | null {
|
|
79
|
+
if (!DOC_SAFE_RE.test(runId) || runId.includes('..')) return 'invalid runId'
|
|
80
|
+
if (!DOC_SAFE_RE.test(file) || file.includes('..') || file.includes('/') || file.includes('\\')) return 'invalid file'
|
|
81
|
+
if (!file.endsWith('.md')) return 'invalid file: must be a .md phase doc'
|
|
82
|
+
if (!PHASE_DOC_FILES.has(file)) return 'invalid file: not a phase doc'
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The lazy per-phase doc route (0.2.4): GET the raw markdown of one run phase
|
|
88
|
+
* doc, read ON DEMAND from the filesystem. NOT part of the /state or /events
|
|
89
|
+
* projection payloads (they stay fold-only). Same browser-marker tripwire as
|
|
90
|
+
* state/events; the client root is re-validated by the host (never trusted:
|
|
91
|
+
* an unknown root -> 400), then the resolved doc path is containment-checked
|
|
92
|
+
* under join(root, '.recursive', 'run', runId).
|
|
93
|
+
*/
|
|
94
|
+
function docRoute(host: RecursiveRouteHost) {
|
|
95
|
+
return {
|
|
96
|
+
kind: 'exact' as const,
|
|
97
|
+
path: RECURSIVE_API_PREFIX + '/doc',
|
|
98
|
+
handler: async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
99
|
+
if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
|
|
100
|
+
if (!browserMarker(req)) { res.writeHead(403); res.end(); return }
|
|
101
|
+
const root = queryOf(req, 'root')
|
|
102
|
+
const runId = queryOf(req, 'runId')
|
|
103
|
+
const file = queryOf(req, 'file')
|
|
104
|
+
const targetError = docTargetError(runId, file)
|
|
105
|
+
if (root === '' || targetError !== null) {
|
|
106
|
+
json(res, 400, { ok: false, error: targetError ?? 'missing root' })
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
// Root validation: the client passes back what the host already resolved.
|
|
110
|
+
// Re-resolve via the host and require a canonical match — never accept an
|
|
111
|
+
// arbitrary path (registry know-it or headless cwd pass-through).
|
|
112
|
+
const resolvedRoot = await host.resolveRoot(undefined, root)
|
|
113
|
+
if (resolvedRoot === null || resolve(resolvedRoot) !== resolve(root)) {
|
|
114
|
+
json(res, 400, { ok: false, error: 'root is not a known workspace' })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
// Containment: the doc must stay under join(root, .recursive, run, runId).
|
|
118
|
+
const runBase = resolve(root, '.recursive', 'run', runId)
|
|
119
|
+
const docPath = resolve(runBase, file)
|
|
120
|
+
if (docPath === runBase || !docPath.startsWith(runBase + sep)) {
|
|
121
|
+
json(res, 400, { ok: false, error: 'doc path escapes the run dir' })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!existsSync(docPath)) {
|
|
125
|
+
json(res, 404, { ok: false, error: 'phase doc not found' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const text = readFileSync(docPath, 'utf8')
|
|
129
|
+
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store' })
|
|
130
|
+
res.end(text)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
/**
|
|
69
|
-
* Build the
|
|
136
|
+
* Build the read-only routes. Returns [state, events, doc] in registration order.
|
|
70
137
|
* @param host - the resolved-root + fs-fold seam (the RecursiveRuntime adapter).
|
|
71
138
|
*/
|
|
72
139
|
export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind: 'exact'; path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void> }[] {
|
|
@@ -109,7 +176,7 @@ export function makeRecursiveRoutes(host: RecursiveRouteHost): readonly { kind:
|
|
|
109
176
|
await push()
|
|
110
177
|
},
|
|
111
178
|
}
|
|
112
|
-
return [state, events]
|
|
179
|
+
return [state, events, docRoute(host)]
|
|
113
180
|
}
|
|
114
181
|
|
|
115
182
|
/**
|