spexcode 0.6.0 → 0.6.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/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync, readdirSync, openSync, closeSync, readSync } from 'node:fs'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
3
4
|
import { sessionStoreDir, sessionArtifactPath, readAliasedRawRecord } from './layout.js'
|
|
4
5
|
import type { Lifecycle, Proposal } from './sessions.js'
|
|
6
|
+
import type { ExecutionTurn } from './execution-trace.js'
|
|
5
7
|
|
|
6
8
|
// @@@ session-timeline - the session's append-only log in its global store dir: every authored-lifecycle
|
|
7
9
|
// transition (status + proposal + the FULL note text) and every message addressed to it. It is what a
|
|
@@ -17,11 +19,97 @@ export type TimelineEvent =
|
|
|
17
19
|
| { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
|
|
18
20
|
| { ts: string; kind: 'sent'; mid: string; text: string; from: string | null; replyVia?: 'note' }
|
|
19
21
|
|
|
22
|
+
export type SentDispatchReceipt = {
|
|
23
|
+
operation: 'merge'
|
|
24
|
+
requestDigest: string
|
|
25
|
+
payloadHash: string
|
|
26
|
+
delivery?: { text: string; from: string | null }
|
|
27
|
+
}
|
|
28
|
+
type DispatchSettlement = { ts: string; kind: 'dispatch-settled'; operation: 'merge'; requestDigest: string; mid: string }
|
|
29
|
+
type StoredTimelineEvent =
|
|
30
|
+
| Extract<TimelineEvent, { kind: 'status' }>
|
|
31
|
+
| (Extract<TimelineEvent, { kind: 'sent' }> & { dispatchReceipt?: SentDispatchReceipt })
|
|
32
|
+
| DispatchSettlement
|
|
33
|
+
|
|
20
34
|
const timelinePath = (id: string): string => sessionArtifactPath(id, 'timeline.ndjson')
|
|
35
|
+
const segmentsDir = (id: string): string => sessionArtifactPath(id, 'timeline')
|
|
36
|
+
const SEGMENT = /^(\d+)\.ndjson$/
|
|
37
|
+
const SEGMENT_NAME_WIDTH = 12
|
|
38
|
+
const TAIL_BLOCK_BYTES = 64 * 1024
|
|
39
|
+
|
|
40
|
+
// One logical timeline is legacy timeline.ndjson followed by immutable numbered segments. The directory
|
|
41
|
+
// listing is its only index: numbering is append order, so there is no mutable manifest to repair.
|
|
42
|
+
function segmentFiles(id: string): string[] {
|
|
43
|
+
try {
|
|
44
|
+
const dir = segmentsDir(id)
|
|
45
|
+
const names = readdirSync(dir).filter((name) => SEGMENT.test(name)).sort((a, b) => {
|
|
46
|
+
const an = BigInt(SEGMENT.exec(a)![1]), bn = BigInt(SEGMENT.exec(b)![1])
|
|
47
|
+
return an < bn ? -1 : an > bn ? 1 : 0
|
|
48
|
+
})
|
|
49
|
+
return names.map((name) => join(dir, name))
|
|
50
|
+
} catch { /* no numbered segments yet */ }
|
|
51
|
+
return []
|
|
52
|
+
}
|
|
21
53
|
|
|
22
|
-
function
|
|
54
|
+
function timelineFiles(id: string): string[] {
|
|
55
|
+
const files: string[] = []
|
|
56
|
+
const legacy = timelinePath(id)
|
|
57
|
+
if (existsSync(legacy)) files.push(legacy)
|
|
58
|
+
files.push(...segmentFiles(id))
|
|
59
|
+
return files
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const segmentLimit = (): number => {
|
|
63
|
+
const configured = Number(process.env.SPEXCODE_TIMELINE_SEGMENT_BYTES)
|
|
64
|
+
return Number.isFinite(configured) ? Math.max(1024, Math.floor(configured)) : 4 * 1024 * 1024
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function activeSegment(id: string, bytes: number): string {
|
|
68
|
+
const dir = segmentsDir(id)
|
|
69
|
+
mkdirSync(dir, { recursive: true })
|
|
70
|
+
const segments = segmentFiles(id)
|
|
71
|
+
const current = segments.at(-1)
|
|
72
|
+
if (!current) return join(dir, `${String(1).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`)
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(current).size === 0 || statSync(current).size + bytes <= segmentLimit()) return current
|
|
75
|
+
} catch { /* a vanished active segment is recreated under its next number */ }
|
|
76
|
+
const n = BigInt(SEGMENT.exec(basename(current))![1]) + 1n
|
|
77
|
+
return join(dir, `${String(n).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function append(id: string, ev: StoredTimelineEvent): void {
|
|
23
81
|
mkdirSync(sessionStoreDir(id), { recursive: true })
|
|
24
|
-
|
|
82
|
+
const line = JSON.stringify(ev) + '\n'
|
|
83
|
+
appendFileSync(activeSegment(id, Buffer.byteLength(line)), line)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseLines(lines: string[]): StoredTimelineEvent[] {
|
|
87
|
+
return lines.map((l) => {
|
|
88
|
+
try { return JSON.parse(l) as StoredTimelineEvent } catch { return null }
|
|
89
|
+
}).filter((e): e is StoredTimelineEvent => e != null && (e.kind === 'status' || e.kind === 'sent' || e.kind === 'dispatch-settled'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function tailPublicEvents(path: string, limit: number): Exclude<StoredTimelineEvent, DispatchSettlement>[] {
|
|
93
|
+
let fd: number | null = null
|
|
94
|
+
try {
|
|
95
|
+
const size = statSync(path).size
|
|
96
|
+
fd = openSync(path, 'r')
|
|
97
|
+
let start = size
|
|
98
|
+
let text = ''
|
|
99
|
+
while (start > 0) {
|
|
100
|
+
const next = Math.max(0, start - TAIL_BLOCK_BYTES)
|
|
101
|
+
const buf = Buffer.alloc(start - next)
|
|
102
|
+
readSync(fd, buf, 0, buf.length, next)
|
|
103
|
+
text = buf.toString('utf8') + text
|
|
104
|
+
const publicCount = parseLines(text.split('\n').filter(Boolean)).filter((event) => event.kind !== 'dispatch-settled').length
|
|
105
|
+
if (publicCount >= limit || next === 0) break
|
|
106
|
+
start = next
|
|
107
|
+
}
|
|
108
|
+
return parseLines(text.split('\n').filter(Boolean))
|
|
109
|
+
.filter((event): event is Exclude<StoredTimelineEvent, DispatchSettlement> => event.kind !== 'dispatch-settled')
|
|
110
|
+
.slice(-limit)
|
|
111
|
+
} catch { return [] }
|
|
112
|
+
finally { if (fd !== null) closeSync(fd) }
|
|
25
113
|
}
|
|
26
114
|
|
|
27
115
|
// Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
|
|
@@ -37,28 +125,70 @@ export function recordStatus(id: string, status: Lifecycle, proposal: Proposal |
|
|
|
37
125
|
// the caller reports the throw rather than a false success. `text` is the message BEFORE any mechanism insert
|
|
38
126
|
// (hints are transport, not conversation); `replyVia` is the effective channel the prompt seam chose. Returns
|
|
39
127
|
// the new line's `mid`, which a best-effort poke carries.
|
|
40
|
-
export function appendSent(id: string, text: string, from: string | null, replyVia?: 'note'): { mid: string } {
|
|
128
|
+
export function appendSent(id: string, text: string, from: string | null, replyVia?: 'note', dispatchReceipt?: SentDispatchReceipt): { mid: string } {
|
|
41
129
|
const mid = randomUUID()
|
|
42
|
-
append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}) })
|
|
130
|
+
append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}), ...(dispatchReceipt ? { dispatchReceipt } : {}) })
|
|
43
131
|
return { mid }
|
|
44
132
|
}
|
|
45
133
|
|
|
134
|
+
export type SentDispatchState = {
|
|
135
|
+
mid: string
|
|
136
|
+
payloadHash: string
|
|
137
|
+
delivery: SentDispatchReceipt['delivery'] | null
|
|
138
|
+
delivered: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function sentDispatchReceipt(id: string, operation: SentDispatchReceipt['operation'], requestDigest: string): SentDispatchState | null {
|
|
142
|
+
let found: Omit<SentDispatchState, 'delivered'> | null = null
|
|
143
|
+
const settled = new Set<string>()
|
|
144
|
+
for (const path of timelineFiles(id)) {
|
|
145
|
+
for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
|
|
146
|
+
if (event.kind === 'sent' && !found && event.dispatchReceipt?.operation === operation && event.dispatchReceipt.requestDigest === requestDigest) {
|
|
147
|
+
found = { mid: event.mid, payloadHash: event.dispatchReceipt.payloadHash, delivery: event.dispatchReceipt.delivery ?? null }
|
|
148
|
+
} else if (event.kind === 'dispatch-settled' && event.operation === operation && event.requestDigest === requestDigest) {
|
|
149
|
+
settled.add(event.mid)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return found ? { ...found, delivered: settled.has(found.mid) } : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function settleSentDispatch(id: string, mid: string): void {
|
|
157
|
+
let receipt: SentDispatchReceipt | null = null
|
|
158
|
+
let settled = false
|
|
159
|
+
for (const path of timelineFiles(id)) {
|
|
160
|
+
for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
|
|
161
|
+
if (event.kind === 'sent' && event.mid === mid && event.dispatchReceipt?.delivery) receipt = event.dispatchReceipt
|
|
162
|
+
if (event.kind === 'dispatch-settled' && event.mid === mid) settled = true
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (!receipt || settled) return
|
|
166
|
+
append(id, { ts: new Date().toISOString(), kind: 'dispatch-settled', operation: receipt.operation, requestDigest: receipt.requestDigest, mid })
|
|
167
|
+
}
|
|
168
|
+
|
|
46
169
|
// The unowned read: any process may take it with nothing but filesystem access, and taking it perturbs
|
|
47
170
|
// nothing. Index = event position, which is what a cursor names ([[session-cursors]]).
|
|
48
171
|
export function timelineEvents(id: string): TimelineEvent[] {
|
|
49
172
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
173
|
+
return timelineFiles(id).flatMap((path) => parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)))
|
|
174
|
+
.flatMap((stored): TimelineEvent[] => {
|
|
175
|
+
if (stored.kind === 'dispatch-settled') return []
|
|
176
|
+
if (stored.kind === 'status') return [stored]
|
|
177
|
+
const { dispatchReceipt: _receipt, ...event } = stored
|
|
178
|
+
return [event]
|
|
179
|
+
})
|
|
55
180
|
} catch { return [] }
|
|
56
181
|
}
|
|
57
182
|
|
|
58
183
|
// the same L0 read taken as CHEAPLY as it can be: a follower ([[session-follow]]) ticks over many logs, so it
|
|
59
184
|
// stats first and parses only what grew. null = no log yet (a session that has authored nothing).
|
|
60
185
|
export function timelineStamp(id: string): string | null {
|
|
61
|
-
try {
|
|
186
|
+
try {
|
|
187
|
+
const path = timelineFiles(id).at(-1)
|
|
188
|
+
if (!path) return null
|
|
189
|
+
const s = statSync(path)
|
|
190
|
+
return `${path}:${s.size}:${s.mtimeMs}`
|
|
191
|
+
}
|
|
62
192
|
catch { return null }
|
|
63
193
|
}
|
|
64
194
|
|
|
@@ -86,13 +216,32 @@ export function lastHumanSendVia(id: string): 'note' | null {
|
|
|
86
216
|
return null
|
|
87
217
|
}
|
|
88
218
|
|
|
219
|
+
// The current human turn is a durable fact from the accepted-message log, not a backend-local generation.
|
|
220
|
+
export function currentHumanTurn(id: string): ExecutionTurn | null {
|
|
221
|
+
const evs = timelineEvents(id)
|
|
222
|
+
for (let i = evs.length - 1; i >= 0; i--) {
|
|
223
|
+
const e = evs[i]
|
|
224
|
+
if (e.kind === 'sent' && e.from == null) return { token: e.mid, acceptedAt: e.ts }
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
89
229
|
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each status
|
|
90
230
|
// event carrying its composed display word. null = no such session (the route 404s).
|
|
91
231
|
export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
|
|
92
232
|
let raw: ReturnType<typeof readAliasedRawRecord>
|
|
93
233
|
try { raw = readAliasedRawRecord(id) } catch { return null }
|
|
94
234
|
if (!raw || !raw.governed) return null
|
|
95
|
-
const
|
|
96
|
-
const tail
|
|
97
|
-
|
|
235
|
+
const wanted = Math.max(1, limit)
|
|
236
|
+
const tail: Exclude<StoredTimelineEvent, DispatchSettlement>[] = []
|
|
237
|
+
for (const path of timelineFiles(id).reverse()) {
|
|
238
|
+
const remaining = wanted - tail.length
|
|
239
|
+
if (remaining <= 0) break
|
|
240
|
+
tail.unshift(...tailPublicEvents(path, remaining))
|
|
241
|
+
}
|
|
242
|
+
return { events: tail.map((e) => {
|
|
243
|
+
if (e.kind === 'status') return { ...e, display: timelineDisplay(e) }
|
|
244
|
+
const { dispatchReceipt: _receipt, ...event } = e
|
|
245
|
+
return event
|
|
246
|
+
}) }
|
|
98
247
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { readRecordEntry, sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
5
|
+
import { projectRuntimeRoot } from './project-store.js'
|
|
6
|
+
|
|
7
|
+
export class SessionWebError extends Error {
|
|
8
|
+
constructor(readonly status: 400 | 403 | 404 | 500, message: string) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'SessionWebError'
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type SessionWebLock = <T>(id: string, body: () => T) => T
|
|
15
|
+
export type SessionWeb = { url: string; key: string }
|
|
16
|
+
|
|
17
|
+
const webPath = (id: string, projectRoot?: string): string => projectRoot
|
|
18
|
+
? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'web.json')
|
|
19
|
+
: sessionArtifactPath(id, 'web.json')
|
|
20
|
+
|
|
21
|
+
const recordPath = (id: string, projectRoot?: string): string => projectRoot
|
|
22
|
+
? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'session.json')
|
|
23
|
+
: join(sessionStoreDir(id), 'session.json')
|
|
24
|
+
|
|
25
|
+
function validSessionId(id: string): boolean {
|
|
26
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireSession(id: string, projectRoot?: string): void {
|
|
30
|
+
if (!validSessionId(id)) throw new SessionWebError(404, `session ${id} does not exist`)
|
|
31
|
+
if (!projectRoot) {
|
|
32
|
+
const record = readRecordEntry(id)
|
|
33
|
+
if (record.kind === 'absent') throw new SessionWebError(404, `session ${id} does not exist`)
|
|
34
|
+
if (record.kind === 'corrupt') throw new SessionWebError(500, `session ${id} has an unreadable record: ${record.error}`)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const path = recordPath(id, projectRoot)
|
|
38
|
+
let record: unknown
|
|
39
|
+
try { record = JSON.parse(readFileSync(path, 'utf8')) }
|
|
40
|
+
catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new SessionWebError(404, `session ${id} does not exist`)
|
|
42
|
+
throw new SessionWebError(500, `session ${id} has an unreadable record: ${error instanceof Error ? error.message : String(error)}`)
|
|
43
|
+
}
|
|
44
|
+
if (!record || typeof record !== 'object' || (record as { session_id?: unknown }).session_id !== id)
|
|
45
|
+
throw new SessionWebError(500, `session ${id} has an unreadable record`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function canonicalSessionWebUrl(input: string): string {
|
|
49
|
+
let url: URL
|
|
50
|
+
try { url = new URL(input.trim()) }
|
|
51
|
+
catch { throw new SessionWebError(400, `invalid local web URL: ${input}`) }
|
|
52
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
53
|
+
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '::1'].includes(host) || !url.port || url.username || url.password) {
|
|
54
|
+
throw new SessionWebError(400, 'web URL must be http://127.0.0.1:<port>/..., http://localhost:<port>/..., or http://[::1]:<port>/...')
|
|
55
|
+
}
|
|
56
|
+
if (url.search || url.hash) throw new SessionWebError(400, 'web URL must not include a query or fragment; publish the service base URL')
|
|
57
|
+
return url.href
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sessionWebKey(url: string): string {
|
|
61
|
+
return createHash('sha256').update(url).digest('base64url')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readWebs(id: string, projectRoot?: string): string[] {
|
|
65
|
+
const path = webPath(id, projectRoot)
|
|
66
|
+
let parsed: unknown
|
|
67
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')) }
|
|
68
|
+
catch (error) {
|
|
69
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
70
|
+
throw new SessionWebError(500, `session web list is unreadable: ${path} — ${error instanceof Error ? error.message : String(error)}`)
|
|
71
|
+
}
|
|
72
|
+
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== 'string'))
|
|
73
|
+
throw new SessionWebError(500, `session web list is invalid: ${path}`)
|
|
74
|
+
try {
|
|
75
|
+
if (parsed.some((value) => canonicalSessionWebUrl(value) !== value)) throw new Error('not canonical')
|
|
76
|
+
} catch {
|
|
77
|
+
throw new SessionWebError(500, `session web list is invalid: ${path}`)
|
|
78
|
+
}
|
|
79
|
+
return [...new Set(parsed)]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function writeWebs(id: string, webs: string[]): void {
|
|
83
|
+
const path = webPath(id)
|
|
84
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(tmp, `${JSON.stringify(webs, null, 2)}\n`)
|
|
87
|
+
renameSync(tmp, path)
|
|
88
|
+
} finally {
|
|
89
|
+
try { unlinkSync(tmp) } catch { /* rename already consumed the temporary file */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const toWeb = (url: string): SessionWeb => ({ url, key: sessionWebKey(url) })
|
|
94
|
+
|
|
95
|
+
export function listSessionWebs(id: string, projectRoot?: string): SessionWeb[] {
|
|
96
|
+
requireSession(id, projectRoot)
|
|
97
|
+
return readSessionWebs(id, projectRoot)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Session projections already hold a parsed record; re-checking session.json here would make an in-memory
|
|
101
|
+
// projection depend on the public route's existence guard. The route-facing list keeps that guard above.
|
|
102
|
+
export function readSessionWebs(id: string, projectRoot?: string): SessionWeb[] {
|
|
103
|
+
return readWebs(id, projectRoot).map(toWeb)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function addSessionWeb(id: string, input: string, lock: SessionWebLock): { url: string; added: boolean } {
|
|
107
|
+
const url = canonicalSessionWebUrl(input)
|
|
108
|
+
return lock(id, () => {
|
|
109
|
+
requireSession(id)
|
|
110
|
+
const webs = readWebs(id)
|
|
111
|
+
if (webs.includes(url)) return { url, added: false }
|
|
112
|
+
writeWebs(id, [...webs, url])
|
|
113
|
+
return { url, added: true }
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function retractSessionWeb(id: string, input: string, lock: SessionWebLock): { url: string; removed: boolean } {
|
|
118
|
+
const url = canonicalSessionWebUrl(input)
|
|
119
|
+
return lock(id, () => {
|
|
120
|
+
requireSession(id)
|
|
121
|
+
const webs = readWebs(id)
|
|
122
|
+
if (!webs.includes(url)) return { url, removed: false }
|
|
123
|
+
writeWebs(id, webs.filter((web) => web !== url))
|
|
124
|
+
return { url, removed: true }
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function postedSessionWeb(id: string, key: string, projectRoot?: string): URL {
|
|
129
|
+
requireSession(id, projectRoot)
|
|
130
|
+
const web = readWebs(id, projectRoot).map(toWeb).find((entry) => entry.key === key)
|
|
131
|
+
if (!web) throw new SessionWebError(403, 'that web service was not posted by this session')
|
|
132
|
+
return new URL(web.url)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const sessionWebsPath = (id: string) => webPath(id)
|