spexcode 0.5.9 → 0.6.1
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/package.json +1 -1
- package/spec-cli/src/cli.ts +69 -70
- package/spec-cli/src/client.ts +1 -1
- 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 +107 -0
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +12 -10
- package/spec-cli/src/help.ts +9 -14
- package/spec-cli/src/index.ts +10 -2
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/session-cursors.ts +11 -17
- package/spec-cli/src/session-follow.ts +6 -6
- package/spec-cli/src/sessions.ts +224 -38
- package/spec-cli/src/uninstall.ts +2 -1
- 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/mark-active/mark-active.sh +5 -41
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
- 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-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- 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 +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import { join, relative, basename } from 'node:path'
|
|
5
|
-
import { mintIds } from '../../spec-cli/src/specs.js'
|
|
5
|
+
import { mintIds, parseFrontmatter } from '../../spec-cli/src/specs.js'
|
|
6
6
|
import { parseRelation, type RelationEntry } from '../../spec-cli/src/anchors.js'
|
|
7
7
|
import { treeTextFiles } from '../../spec-cli/src/git.js'
|
|
8
8
|
|
|
@@ -41,13 +41,20 @@ export type ScenarioProjectionRow = {
|
|
|
41
41
|
semantic: ScenarioSemanticRow
|
|
42
42
|
measurement: ScenarioMeasurementRow
|
|
43
43
|
}
|
|
44
|
+
export type ScenarioProjectionNode = {
|
|
45
|
+
id: string
|
|
46
|
+
code: RelationEntry[]
|
|
47
|
+
related: RelationEntry[]
|
|
48
|
+
}
|
|
44
49
|
export type ScenarioProjectionProvenance = { head: string | null; treeSha: string | null }
|
|
45
50
|
export type ScenarioProjection = {
|
|
46
51
|
projection: typeof SCENARIO_PROJECTION
|
|
47
52
|
schemaVersion: typeof SCENARIO_SCHEMA_VERSION
|
|
48
53
|
provenance: ScenarioProjectionProvenance
|
|
54
|
+
nodes: ScenarioProjectionNode[]
|
|
49
55
|
semanticIndexHash: string
|
|
50
56
|
fullIndexHash: string
|
|
57
|
+
planningIndexHash: string
|
|
51
58
|
rows: ScenarioProjectionRow[]
|
|
52
59
|
}
|
|
53
60
|
|
|
@@ -58,6 +65,7 @@ export type EvalNode = {
|
|
|
58
65
|
sidecarPath: string
|
|
59
66
|
scenarios: Scenario[]
|
|
60
67
|
evalSource?: string
|
|
68
|
+
specSource?: string
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'] as const
|
|
@@ -349,11 +357,29 @@ const hashProjection = (value: unknown): string =>
|
|
|
349
357
|
createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex')
|
|
350
358
|
|
|
351
359
|
const semanticOnly = (row: ScenarioProjectionRow): ScenarioSemanticRow => row.semantic
|
|
360
|
+
const frontmatterList = (value: string | string[] | undefined): string[] =>
|
|
361
|
+
Array.isArray(value) ? value : value ? [value] : []
|
|
362
|
+
|
|
363
|
+
function projectionNode(node: Pick<EvalNode, 'id' | 'specSource'>): ScenarioProjectionNode {
|
|
364
|
+
const fm = node.specSource ? parseFrontmatter(node.specSource).fm : {}
|
|
365
|
+
const code = parseRelation(frontmatterList(fm.code), 'code')
|
|
366
|
+
const related = parseRelation(frontmatterList(fm.related), 'related')
|
|
367
|
+
const problems = [...code.problems, ...related.problems]
|
|
368
|
+
if (problems.length) {
|
|
369
|
+
throw new Error(`node '${node.id}' has malformed spec relations:\n${problems.map((e) => ` - ${e}`).join('\n')}`)
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
id: node.id,
|
|
373
|
+
code: code.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
|
|
374
|
+
related: related.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
|
|
375
|
+
}
|
|
376
|
+
}
|
|
352
377
|
|
|
353
378
|
export function scenarioProjection(
|
|
354
|
-
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource'>[],
|
|
379
|
+
nodes: readonly Pick<EvalNode, 'id' | 'scenarios' | 'evalSource' | 'specSource'>[],
|
|
355
380
|
provenance: Partial<ScenarioProjectionProvenance> = {},
|
|
356
381
|
): ScenarioProjection {
|
|
382
|
+
const nodeRows = nodes.map(projectionNode).sort((a, b) => compareStable(a.id, b.id))
|
|
357
383
|
const rows: ScenarioProjectionRow[] = []
|
|
358
384
|
for (const node of nodes) {
|
|
359
385
|
if ('evalSource' in node && node.evalSource !== undefined) {
|
|
@@ -388,8 +414,10 @@ export function scenarioProjection(
|
|
|
388
414
|
projection: SCENARIO_PROJECTION,
|
|
389
415
|
schemaVersion: SCENARIO_SCHEMA_VERSION,
|
|
390
416
|
provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
|
|
417
|
+
nodes: nodeRows,
|
|
391
418
|
semanticIndexHash: hashProjection(semanticRows),
|
|
392
419
|
fullIndexHash: hashProjection(rows),
|
|
420
|
+
planningIndexHash: hashProjection({ nodes: nodeRows, rows }),
|
|
393
421
|
rows,
|
|
394
422
|
}
|
|
395
423
|
}
|
|
@@ -543,18 +571,19 @@ export function writeScenarioMeasurementMetadata(source: string, request: unknow
|
|
|
543
571
|
return proposed
|
|
544
572
|
}
|
|
545
573
|
|
|
546
|
-
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string }[]): EvalNode[] {
|
|
574
|
+
function assembleNodes(root: string, specDirs: string[], hits: { dir: string; src: string; specSource?: string }[]): EvalNode[] {
|
|
547
575
|
const specBase = join(root, '.spec')
|
|
548
576
|
const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)))
|
|
549
577
|
const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]))
|
|
550
578
|
return hits
|
|
551
|
-
.map(({ dir, src }) => ({
|
|
579
|
+
.map(({ dir, src, specSource }) => ({
|
|
552
580
|
id: idByDir.get(dir) ?? basename(dir),
|
|
553
581
|
dir,
|
|
554
582
|
evalPath: relative(root, join(dir, EVAL_FILE)),
|
|
555
583
|
sidecarPath: join(dir, SIDECAR_FILE),
|
|
556
584
|
scenarios: parseScenarios(src),
|
|
557
585
|
evalSource: src,
|
|
586
|
+
...(specSource !== undefined ? { specSource } : {}),
|
|
558
587
|
}))
|
|
559
588
|
.sort((a, b) => a.id.localeCompare(b.id))
|
|
560
589
|
}
|
|
@@ -562,14 +591,21 @@ function assembleNodes(root: string, specDirs: string[], hits: { dir: string; sr
|
|
|
562
591
|
export function evalNodes(root: string): EvalNode[] {
|
|
563
592
|
const specDir = join(root, '.spec')
|
|
564
593
|
const specDirs: string[] = []
|
|
565
|
-
const hits: { dir: string; src: string }[] = []
|
|
594
|
+
const hits: { dir: string; src: string; specSource?: string }[] = []
|
|
566
595
|
const stack = existsSync(specDir) ? [specDir] : []
|
|
567
596
|
while (stack.length) {
|
|
568
597
|
const dir = stack.pop()!
|
|
569
598
|
let ents
|
|
570
599
|
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
|
571
600
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
572
|
-
if (existsSync(join(dir, EVAL_FILE)))
|
|
601
|
+
if (existsSync(join(dir, EVAL_FILE))) {
|
|
602
|
+
const specPath = join(dir, 'spec.md')
|
|
603
|
+
hits.push({
|
|
604
|
+
dir,
|
|
605
|
+
src: readFileSync(join(dir, EVAL_FILE), 'utf8'),
|
|
606
|
+
...(existsSync(specPath) ? { specSource: readFileSync(specPath, 'utf8') } : {}),
|
|
607
|
+
})
|
|
608
|
+
}
|
|
573
609
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
574
610
|
}
|
|
575
611
|
return assembleNodes(root, specDirs, hits)
|
|
@@ -583,10 +619,15 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
|
583
619
|
.map((path) => join(root, path.slice(0, -'/spec.md'.length)))
|
|
584
620
|
const hits = paths
|
|
585
621
|
.filter((path) => path.endsWith(`/${EVAL_FILE}`))
|
|
586
|
-
.map((path) =>
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
622
|
+
.map((path) => {
|
|
623
|
+
const relDir = path.slice(0, -`/${EVAL_FILE}`.length)
|
|
624
|
+
const specSource = files.get(`${relDir}/spec.md`)
|
|
625
|
+
return {
|
|
626
|
+
dir: join(root, relDir),
|
|
627
|
+
src: files.get(path)!,
|
|
628
|
+
...(specSource !== undefined ? { specSource } : {}),
|
|
629
|
+
}
|
|
630
|
+
})
|
|
590
631
|
return assembleNodes(root, specDirs, hits)
|
|
591
632
|
}
|
|
592
633
|
|
|
@@ -597,14 +638,21 @@ export function evalNodesAt(root: string, tip: string): EvalNode[] {
|
|
|
597
638
|
export async function evalNodesAsync(root: string): Promise<EvalNode[]> {
|
|
598
639
|
const specDir = join(root, '.spec')
|
|
599
640
|
const specDirs: string[] = []
|
|
600
|
-
const hits: { dir: string; src: string }[] = []
|
|
641
|
+
const hits: { dir: string; src: string; specSource?: string }[] = []
|
|
601
642
|
const stack = existsSync(specDir) ? [specDir] : []
|
|
602
643
|
while (stack.length) {
|
|
603
644
|
const dir = stack.pop()!
|
|
604
645
|
let ents
|
|
605
646
|
try { ents = await readdir(dir, { withFileTypes: true }) } catch { continue }
|
|
606
647
|
if (existsSync(join(dir, 'spec.md'))) specDirs.push(dir)
|
|
607
|
-
if (existsSync(join(dir, EVAL_FILE)))
|
|
648
|
+
if (existsSync(join(dir, EVAL_FILE))) {
|
|
649
|
+
const specPath = join(dir, 'spec.md')
|
|
650
|
+
hits.push({
|
|
651
|
+
dir,
|
|
652
|
+
src: await readFile(join(dir, EVAL_FILE), 'utf8'),
|
|
653
|
+
...(existsSync(specPath) ? { specSource: await readFile(specPath, 'utf8') } : {}),
|
|
654
|
+
})
|
|
655
|
+
}
|
|
608
656
|
for (const e of ents) if (e.isDirectory()) stack.push(join(dir, e.name))
|
|
609
657
|
}
|
|
610
658
|
return assembleNodes(root, specDirs, hits)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
|
-
import { dirname, join, relative } from 'node:path'
|
|
4
|
+
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
5
5
|
import {
|
|
6
6
|
batchBlobTexts,
|
|
7
7
|
batchRevisionOids,
|
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
type DriftPathEvent,
|
|
18
18
|
type ReviewDiffFile,
|
|
19
19
|
} from '../../spec-cli/src/git.js'
|
|
20
|
+
import { touchRoot } from '../../spec-cli/src/root-lru.js'
|
|
20
21
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
21
22
|
import { mainBranch } from '../../spec-cli/src/layout.js'
|
|
22
|
-
import { reviewPayload } from '../../spec-cli/src/sessions.js'
|
|
23
|
+
import { reviewIdentity, reviewPayload, type ReviewIdentity } from '../../spec-cli/src/sessions.js'
|
|
23
24
|
import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
|
|
24
25
|
import {
|
|
25
26
|
anchorHitCommits,
|
|
@@ -400,11 +401,45 @@ function parsedRelation(raw: readonly string[], relation: 'code' | 'related', ow
|
|
|
400
401
|
// Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
|
|
401
402
|
// immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
|
|
402
403
|
// before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
|
|
404
|
+
// @@@ the projection is the PERMANENT half ([[taste]] 19) - what commits between two immutable trees changed
|
|
405
|
+
// which paths, and what each side declared, are facts about those commits: they cannot change, so a repeat
|
|
406
|
+
// projection over the same inputs is pure recomputation. It used to be deliberately build-local, which was
|
|
407
|
+
// right while it was cheap; on a branch 840 commits from its base it is 2.4s, and a detail open pays it
|
|
408
|
+
// EVERY time because a focused build deposits no cut. Every input that can move the result is in the key —
|
|
409
|
+
// the two resolved commit oids, the live overlay's own content revision, and the measurement axis — so a hit
|
|
410
|
+
// answers the identical question. The promise is cached, not the value, so concurrent openers join one
|
|
411
|
+
// projection; a rejection is never retained, keeping an unavailable selector loud on every read.
|
|
412
|
+
const impactSlots = Math.max(4, Number(process.env.SPEXCODE_IMPACT_SLOTS || 32))
|
|
413
|
+
const impactRoots = new Map<string, string>()
|
|
414
|
+
const impactMemo = new Map<string, Promise<SessionImpactProjection>>()
|
|
415
|
+
function impactKey(root: string, base: string, head: string, options: SessionImpactOptions): string {
|
|
416
|
+
const measurements = Object.entries(options.measurements ?? {})
|
|
417
|
+
.map(([node, names]) => `${node}:${[...names].sort().join(',')}`).sort().join('\u001f')
|
|
418
|
+
return [resolvePath(root), base, head, options.overlay?.revision ?? '', createHash('sha256').update(measurements).digest('hex')].join('\0')
|
|
419
|
+
}
|
|
420
|
+
|
|
403
421
|
export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
|
|
404
422
|
const [base, head] = await Promise.all([
|
|
405
423
|
impactCommit(root, options.base),
|
|
406
424
|
impactCommit(root, options.head),
|
|
407
425
|
])
|
|
426
|
+
const memoKey = impactKey(root, base, head, options)
|
|
427
|
+
const memoHit = impactMemo.get(memoKey)
|
|
428
|
+
if (memoHit) return memoHit
|
|
429
|
+
const flight = projectSessionImpactUncached(root, options, base, head)
|
|
430
|
+
impactMemo.set(memoKey, flight)
|
|
431
|
+
flight.catch(() => { if (impactMemo.get(memoKey) === flight) impactMemo.delete(memoKey) })
|
|
432
|
+
touchRoot(impactRoots, impactMemo, resolvePath(root), memoKey, impactSlots)
|
|
433
|
+
return flight
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// the slow, obviously-correct recompute — kept whole as the specification the cached path must equal
|
|
437
|
+
export async function projectSessionImpactUncached(
|
|
438
|
+
root: string,
|
|
439
|
+
options: SessionImpactOptions,
|
|
440
|
+
base: string,
|
|
441
|
+
head: string,
|
|
442
|
+
): Promise<SessionImpactProjection> {
|
|
408
443
|
const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
|
|
409
444
|
if (!ancestry.ok) {
|
|
410
445
|
if (ancestry.failure === 'exit') {
|
|
@@ -413,7 +448,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
413
448
|
throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
|
|
414
449
|
}
|
|
415
450
|
// One immutable .spec tree read per distinct revision supplies BOTH spec relations and eval declarations.
|
|
416
|
-
//
|
|
451
|
+
// Build-local by design: the memo above owns reuse ACROSS builds, this map owns it within one.
|
|
417
452
|
const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
|
|
418
453
|
const readTree = (revision: string): ReadonlyMap<string, string> => {
|
|
419
454
|
const cached = treeByRevision.get(revision)
|
|
@@ -1383,13 +1418,23 @@ export type SessionEvals = {
|
|
|
1383
1418
|
node: string | null
|
|
1384
1419
|
branch: string | null
|
|
1385
1420
|
title: string
|
|
1386
|
-
ahead: number
|
|
1387
|
-
dirtyNonRuntime: number
|
|
1388
|
-
gates: ExportGate[]
|
|
1389
1421
|
nodes: SessionEvalNode[]
|
|
1390
1422
|
impact: SessionImpactProjection
|
|
1391
|
-
|
|
1423
|
+
// the whole scope's seven counts. A FOCUSED build cannot honestly produce them — it measured six rows,
|
|
1424
|
+
// not the scope — so it carries the summary only when the cut already holds one, and otherwise omits it
|
|
1425
|
+
// rather than publishing a fold over the window. `evalRevision.content` is the stronger identity anyway:
|
|
1426
|
+
// equal content revision IS the same evaluation cut, so a consumer fencing on it needs no counts.
|
|
1427
|
+
summary?: SessionEvalSummary
|
|
1428
|
+
// the LIST page's chrome, and only its. A focused build renders no gates strip and no branch counters, so
|
|
1429
|
+
// it does not buy them — see `order` below for what marks such a model partial.
|
|
1430
|
+
gates?: ExportGate[]
|
|
1431
|
+
ahead?: number
|
|
1432
|
+
dirtyNonRuntime?: number
|
|
1392
1433
|
evalRevision: SessionEvalRevision
|
|
1434
|
+
// present ONLY on a focused build: the whole population's identity+sequence facts, because `nodes` then
|
|
1435
|
+
// holds just the few the response will render. Its presence is exactly what marks a model as PARTIAL —
|
|
1436
|
+
// such a model may never enter the shared cut, or the list page would read the scope as those few nodes.
|
|
1437
|
+
order?: SessionEvalOrderRow[]
|
|
1393
1438
|
}
|
|
1394
1439
|
|
|
1395
1440
|
export type SessionEvalSummary = {
|
|
@@ -1449,7 +1494,7 @@ async function sessionScopeNodes(
|
|
|
1449
1494
|
ctx: Awaited<ReturnType<typeof evalContext>>,
|
|
1450
1495
|
impact: SessionImpactProjection,
|
|
1451
1496
|
shas: ReadonlySet<string>,
|
|
1452
|
-
|
|
1497
|
+
freshness: { order?: boolean; only?: ReadonlySet<string> } = {},
|
|
1453
1498
|
): Promise<SessionEvalNode[]> {
|
|
1454
1499
|
const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
|
|
1455
1500
|
const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
|
|
@@ -1460,10 +1505,13 @@ async function sessionScopeNodes(
|
|
|
1460
1505
|
// instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
|
|
1461
1506
|
const timelineIds = impact.nodes
|
|
1462
1507
|
.filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
|
|
1508
|
+
.filter((projected) => !freshness.only || freshness.only.has(projected.id))
|
|
1463
1509
|
.map((projected) => projected.id)
|
|
1464
|
-
const timelineById = new Map((await evalTimelines(timelineIds, ctx
|
|
1510
|
+
const timelineById = new Map((await evalTimelines(timelineIds, ctx, { order: freshness.order }))
|
|
1511
|
+
.map((timeline, i) => [timelineIds[i], timeline]))
|
|
1465
1512
|
|
|
1466
1513
|
for (const projected of impact.nodes) {
|
|
1514
|
+
if (freshness.only && !freshness.only.has(projected.id)) continue
|
|
1467
1515
|
const spec = specById.get(projected.id)
|
|
1468
1516
|
if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
|
|
1469
1517
|
const evalNode = evalById.get(spec.id)
|
|
@@ -1507,7 +1555,7 @@ async function sessionScopeNodes(
|
|
|
1507
1555
|
scenarios: scoped.scenarios,
|
|
1508
1556
|
// Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
|
|
1509
1557
|
// honest downstream states; impact selection never removes a row because its reading is stale.
|
|
1510
|
-
evals:
|
|
1558
|
+
evals: scoped.evals,
|
|
1511
1559
|
})
|
|
1512
1560
|
}
|
|
1513
1561
|
|
|
@@ -1570,9 +1618,9 @@ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
|
|
|
1570
1618
|
|
|
1571
1619
|
async function buildSessionEvalModel(
|
|
1572
1620
|
id: string,
|
|
1573
|
-
payload: ReviewPayloadValue,
|
|
1621
|
+
payload: ReviewPayloadValue | ReviewIdentity,
|
|
1574
1622
|
wtPath: string | null,
|
|
1575
|
-
|
|
1623
|
+
pick?: SessionEvalFocus,
|
|
1576
1624
|
): Promise<SessionEvalModel> {
|
|
1577
1625
|
// spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
|
|
1578
1626
|
// in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
|
|
@@ -1582,23 +1630,51 @@ async function buildSessionEvalModel(
|
|
|
1582
1630
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
1583
1631
|
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
1584
1632
|
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
|
|
1585
|
-
|
|
1633
|
+
// @@@ TWO passes, ONE context - a detail open renders one scenario and at most five neighbours, but it
|
|
1634
|
+
// still owes the full population's sequence (its `index` and `total`). Sequence is freshness-free, so the
|
|
1635
|
+
// first pass reads every node's rows with no probes at all (~0.4s of the 25s), the caller names the few
|
|
1636
|
+
// nodes whose verdicts will actually be published, and only those pay the freshness pass. The context and
|
|
1637
|
+
// the impact projection are built once and shared, so the cheap pass adds no second projection.
|
|
1638
|
+
let order: SessionEvalOrderRow[] | undefined
|
|
1639
|
+
let only: ReadonlySet<string> | undefined
|
|
1640
|
+
if (pick) {
|
|
1641
|
+
const draft = await sessionScopeNodes(id, ctx, impact, shas, { order: true })
|
|
1642
|
+
order = orderRowsOf(draft)
|
|
1643
|
+
only = new Set(pick(order))
|
|
1644
|
+
}
|
|
1645
|
+
const nodes = await sessionScopeNodes(id, ctx, impact, shas, only ? { only } : {})
|
|
1586
1646
|
// nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
|
|
1587
1647
|
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
|
|
1588
1648
|
|| (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
|
|
1589
1649
|
|
|
1590
1650
|
const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
|
|
1651
|
+
const full = 'gates' in payload ? payload : null
|
|
1591
1652
|
return {
|
|
1592
1653
|
id,
|
|
1593
1654
|
node: payload.node,
|
|
1594
1655
|
branch: payload.branch,
|
|
1595
1656
|
title: primary || payload.node || payload.branch || id.slice(0, 8),
|
|
1596
|
-
ahead:
|
|
1597
|
-
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
1598
|
-
gates: gateRows(payload),
|
|
1657
|
+
...(full ? { ahead: full.ahead, dirtyNonRuntime: full.dirtyNonRuntime, gates: gateRows(full) } : {}),
|
|
1599
1658
|
nodes,
|
|
1600
1659
|
impact,
|
|
1660
|
+
...(order ? { order } : {}),
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// the identity+sequence facts a detail open needs about scenarios it will NOT render: which exist, whether
|
|
1665
|
+
// they carry a filed reading, and when. Deliberately not an EvalEntry — there is no verdict here to leak.
|
|
1666
|
+
export type SessionEvalOrderRow = { node: string; scenario: string; ts: string | null }
|
|
1667
|
+
export type SessionEvalFocus = (order: SessionEvalOrderRow[]) => readonly string[]
|
|
1668
|
+
|
|
1669
|
+
export function orderRowsOf(nodes: SessionEvalNode[]): SessionEvalOrderRow[] {
|
|
1670
|
+
const rows: SessionEvalOrderRow[] = []
|
|
1671
|
+
for (const node of nodes) {
|
|
1672
|
+
const latest = new Map<string, string>()
|
|
1673
|
+
for (const reading of node.evals) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading.ts)
|
|
1674
|
+
for (const scenario of node.scenarios)
|
|
1675
|
+
rows.push({ node: node.id, scenario: scenario.name, ts: latest.get(scenario.name) ?? null })
|
|
1601
1676
|
}
|
|
1677
|
+
return rows
|
|
1602
1678
|
}
|
|
1603
1679
|
|
|
1604
1680
|
function untrackedPaths(status: string): string[] {
|
|
@@ -2017,22 +2093,26 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
|
|
|
2017
2093
|
? { kind: 'stable', revision: after, summary: cached.summary }
|
|
2018
2094
|
: { kind: 'unstable' }
|
|
2019
2095
|
}
|
|
2020
|
-
const model = await buildSessionEvalModel(id, payload, wtPath
|
|
2096
|
+
const model = await buildSessionEvalModel(id, payload, wtPath)
|
|
2021
2097
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2022
2098
|
if (before !== after) return { kind: 'unstable' }
|
|
2023
2099
|
const summary = sessionEvalSummary(model.nodes)
|
|
2024
|
-
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
2025
|
-
// fold
|
|
2026
|
-
depositStableCut(id, after, { summary })
|
|
2100
|
+
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
2101
|
+
// This fold IS the demand's fold, so it deposits the model too and a later open replays it.
|
|
2102
|
+
depositStableCut(id, after, { summary, model })
|
|
2027
2103
|
return { kind: 'stable', revision: after, summary }
|
|
2028
2104
|
}
|
|
2029
2105
|
|
|
2030
|
-
// ONE content-addressed cut per session, keyed by id + content revision
|
|
2031
|
-
// reads and
|
|
2032
|
-
// @@@
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2106
|
+
// ONE content-addressed cut per session, keyed by id + content revision, carrying the summary the graph
|
|
2107
|
+
// reads and the full model a repeat open replays.
|
|
2108
|
+
// @@@one builder, not two - these used to be separate folds: the graph's built latestOnly (latest reading
|
|
2109
|
+
// per scenario) and deposited counts alone, so the demand path re-derived the WHOLE thing to get history
|
|
2110
|
+
// the graph fold had held one line earlier and thrown away. The trim never made the fold cheaper — the
|
|
2111
|
+
// expensive part is the freshness pass over every node in scope, and it ran identically either way — and
|
|
2112
|
+
// it never mattered to the counts, because sessionEvalSummary folds latestPerScenario itself. So the trim
|
|
2113
|
+
// bought nothing and cost a second full build per session per revision. The price of dropping it is
|
|
2114
|
+
// MEMORY: a cut now retains complete A/B history rather than latest-per-scenario, bounded by the one-cut-
|
|
2115
|
+
// per-session rule below.
|
|
2036
2116
|
type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
|
|
2037
2117
|
const summaryByContent = new Map<string, StableCut>()
|
|
2038
2118
|
|
|
@@ -2078,12 +2158,18 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
|
|
|
2078
2158
|
}
|
|
2079
2159
|
export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
|
|
2080
2160
|
|
|
2081
|
-
|
|
2161
|
+
// `pick` makes this a FOCUSED build: the caller names, from the population's sequence, the few nodes whose
|
|
2162
|
+
// verdicts its response will publish, and only those pay the freshness pass. A focused build is PARTIAL, so
|
|
2163
|
+
// it never deposits — but it still prefers a cached FULL model when one exists, because a complete answer
|
|
2164
|
+
// already paid for beats a cheap incomplete one.
|
|
2165
|
+
export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Promise<SessionEvals | null> {
|
|
2082
2166
|
// A full model is demand-only, but it still runs through the projection queue. This gives a selected session
|
|
2083
2167
|
// priority over unrelated queued summaries without opening a second git/build lane.
|
|
2084
2168
|
for (;;) {
|
|
2085
2169
|
const attempt = await projectionCache.demand(id, '', async () => {
|
|
2086
|
-
|
|
2170
|
+
// a focused open renders no gates strip, so it reads the session's IDENTITY (a free store read)
|
|
2171
|
+
// instead of its review payload (ahead count + dirty scan + merge-tree conflict probe).
|
|
2172
|
+
const payload = pick ? reviewIdentity(id) : await reviewPayload(id)
|
|
2087
2173
|
if (!payload) return { kind: 'missing' as const }
|
|
2088
2174
|
const wtPath = worktreePathForBranch(payload.branch)
|
|
2089
2175
|
const ctxPath = wtPath ?? repoRoot()
|
|
@@ -2108,7 +2194,7 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2108
2194
|
if (before !== settled) projectionCache.invalidate({ id })
|
|
2109
2195
|
return { kind: 'retry' as const }
|
|
2110
2196
|
}
|
|
2111
|
-
const model = await buildSessionEvalModel(id, payload, wtPath,
|
|
2197
|
+
const model = await buildSessionEvalModel(id, payload, wtPath, pick)
|
|
2112
2198
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2113
2199
|
const current = projectionCache.get(id)
|
|
2114
2200
|
if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
|
|
@@ -2116,6 +2202,11 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2116
2202
|
if (before !== after) projectionCache.invalidate({ id })
|
|
2117
2203
|
return { kind: 'retry' as const }
|
|
2118
2204
|
}
|
|
2205
|
+
// A focused model holds only the nodes it was asked to publish, so its counts are NOT the scope's and
|
|
2206
|
+
// its nodes are NOT the scope's. Neither may be deposited or accepted: doing so would tell the list
|
|
2207
|
+
// page and the graph that the session's whole evaluation is those few nodes.
|
|
2208
|
+
if (model.order)
|
|
2209
|
+
return { kind: 'ready' as const, model, summary: known?.value, generation, revision: after }
|
|
2119
2210
|
const summary = sessionEvalSummary(model.nodes)
|
|
2120
2211
|
// Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
|
|
2121
2212
|
// neither line, so a failure can never poison the entry.
|
|
@@ -2127,7 +2218,8 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2127
2218
|
if (attempt.kind === 'retry') continue
|
|
2128
2219
|
return {
|
|
2129
2220
|
...attempt.model,
|
|
2130
|
-
summary
|
|
2221
|
+
// absent on a focused build whose cut holds no summary — never a fold over the rendered window
|
|
2222
|
+
...(attempt.summary ? { summary: attempt.summary } : {}),
|
|
2131
2223
|
evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
|
|
2132
2224
|
}
|
|
2133
2225
|
}
|