@try-works/dsh-recursive-mode 0.2.3 → 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/doc-viewer.d.ts +40 -0
- package/lib/client/host-api.d.ts +13 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client.js +813 -58
- package/lib/index.js +142 -59
- package/lib/live-route.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/doc-viewer.tsx +306 -0
- package/src/client/host-api.ts +28 -0
- package/src/client/index.ts +1 -0
- package/src/client/inspector.tsx +13 -2
- package/src/client/styles.ts +310 -0
- package/src/live-route.ts +69 -2
package/src/client/styles.ts
CHANGED
|
@@ -532,6 +532,316 @@ const BOARD_CSS = `
|
|
|
532
532
|
color: var(--board-muted-fg);
|
|
533
533
|
}
|
|
534
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
|
+
|
|
535
845
|
/* ===== Strip (session dock) — keeps the shell --dsw-* theme ===== */
|
|
536
846
|
.rec-badge {
|
|
537
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
|
/**
|