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,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,
|
|
@@ -13,13 +13,16 @@ import {
|
|
|
13
13
|
driftIndex,
|
|
14
14
|
historyIndex,
|
|
15
15
|
treeTextFiles,
|
|
16
|
+
withEventLedgerBuild,
|
|
17
|
+
withEventLedgerDemand,
|
|
16
18
|
type DriftIndex,
|
|
17
19
|
type DriftPathEvent,
|
|
18
20
|
type ReviewDiffFile,
|
|
19
21
|
} from '../../spec-cli/src/git.js'
|
|
22
|
+
import { touchRoot } from '../../spec-cli/src/root-lru.js'
|
|
20
23
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
21
24
|
import { mainBranch } from '../../spec-cli/src/layout.js'
|
|
22
|
-
import { reviewPayload } from '../../spec-cli/src/sessions.js'
|
|
25
|
+
import { reviewIdentity, reviewPayload, type ReviewIdentity } from '../../spec-cli/src/sessions.js'
|
|
23
26
|
import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
|
|
24
27
|
import {
|
|
25
28
|
anchorHitCommits,
|
|
@@ -120,7 +123,7 @@ type LoadedSpec = Awaited<ReturnType<typeof loadSpecs>>[number]
|
|
|
120
123
|
async function impactGit(root: string, args: string[], operation: string): Promise<string> {
|
|
121
124
|
const result = await gitTry(['-C', root, ...args])
|
|
122
125
|
if (!result.ok) {
|
|
123
|
-
const detail = result.stderr.trim()
|
|
126
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
|
|
124
127
|
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
125
128
|
}
|
|
126
129
|
return result.stdout
|
|
@@ -129,7 +132,7 @@ async function impactGit(root: string, args: string[], operation: string): Promi
|
|
|
129
132
|
async function impactIndexGit(root: string, indexFile: string, args: string[], operation: string): Promise<string> {
|
|
130
133
|
const result = await gitTry(['-C', root, ...args], { indexFile })
|
|
131
134
|
if (!result.ok) {
|
|
132
|
-
const detail = result.stderr.trim()
|
|
135
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
|
|
133
136
|
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
134
137
|
}
|
|
135
138
|
return result.stdout
|
|
@@ -400,11 +403,45 @@ function parsedRelation(raw: readonly string[], relation: 'code' | 'related', ow
|
|
|
400
403
|
// Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
|
|
401
404
|
// immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
|
|
402
405
|
// before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
|
|
406
|
+
// @@@ the projection is the PERMANENT half ([[taste]] 19) - what commits between two immutable trees changed
|
|
407
|
+
// which paths, and what each side declared, are facts about those commits: they cannot change, so a repeat
|
|
408
|
+
// projection over the same inputs is pure recomputation. It used to be deliberately build-local, which was
|
|
409
|
+
// right while it was cheap; on a branch 840 commits from its base it is 2.4s, and a detail open pays it
|
|
410
|
+
// EVERY time because a focused build deposits no cut. Every input that can move the result is in the key —
|
|
411
|
+
// the two resolved commit oids, the live overlay's own content revision, and the measurement axis — so a hit
|
|
412
|
+
// answers the identical question. The promise is cached, not the value, so concurrent openers join one
|
|
413
|
+
// projection; a rejection is never retained, keeping an unavailable selector loud on every read.
|
|
414
|
+
const impactSlots = Math.max(4, Number(process.env.SPEXCODE_IMPACT_SLOTS || 32))
|
|
415
|
+
const impactRoots = new Map<string, string>()
|
|
416
|
+
const impactMemo = new Map<string, Promise<SessionImpactProjection>>()
|
|
417
|
+
function impactKey(root: string, base: string, head: string, options: SessionImpactOptions): string {
|
|
418
|
+
const measurements = Object.entries(options.measurements ?? {})
|
|
419
|
+
.map(([node, names]) => `${node}:${[...names].sort().join(',')}`).sort().join('\u001f')
|
|
420
|
+
return [resolvePath(root), base, head, options.overlay?.revision ?? '', createHash('sha256').update(measurements).digest('hex')].join('\0')
|
|
421
|
+
}
|
|
422
|
+
|
|
403
423
|
export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
|
|
404
424
|
const [base, head] = await Promise.all([
|
|
405
425
|
impactCommit(root, options.base),
|
|
406
426
|
impactCommit(root, options.head),
|
|
407
427
|
])
|
|
428
|
+
const memoKey = impactKey(root, base, head, options)
|
|
429
|
+
const memoHit = impactMemo.get(memoKey)
|
|
430
|
+
if (memoHit) return memoHit
|
|
431
|
+
const flight = projectSessionImpactUncached(root, options, base, head)
|
|
432
|
+
impactMemo.set(memoKey, flight)
|
|
433
|
+
flight.catch(() => { if (impactMemo.get(memoKey) === flight) impactMemo.delete(memoKey) })
|
|
434
|
+
touchRoot(impactRoots, impactMemo, resolvePath(root), memoKey, impactSlots)
|
|
435
|
+
return flight
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// the slow, obviously-correct recompute — kept whole as the specification the cached path must equal
|
|
439
|
+
export async function projectSessionImpactUncached(
|
|
440
|
+
root: string,
|
|
441
|
+
options: SessionImpactOptions,
|
|
442
|
+
base: string,
|
|
443
|
+
head: string,
|
|
444
|
+
): Promise<SessionImpactProjection> {
|
|
408
445
|
const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
|
|
409
446
|
if (!ancestry.ok) {
|
|
410
447
|
if (ancestry.failure === 'exit') {
|
|
@@ -413,7 +450,7 @@ export async function projectSessionImpact(root: string, options: SessionImpactO
|
|
|
413
450
|
throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
|
|
414
451
|
}
|
|
415
452
|
// One immutable .spec tree read per distinct revision supplies BOTH spec relations and eval declarations.
|
|
416
|
-
//
|
|
453
|
+
// Build-local by design: the memo above owns reuse ACROSS builds, this map owns it within one.
|
|
417
454
|
const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
|
|
418
455
|
const readTree = (revision: string): ReadonlyMap<string, string> => {
|
|
419
456
|
const cached = treeByRevision.get(revision)
|
|
@@ -763,14 +800,23 @@ export type ExportModel = {
|
|
|
763
800
|
|
|
764
801
|
// null when no session has that id (route → 404).
|
|
765
802
|
export async function buildExportModel(id: string): Promise<ExportModel | null> {
|
|
766
|
-
const
|
|
767
|
-
if (!
|
|
803
|
+
const identity = reviewIdentity(id)
|
|
804
|
+
if (!identity) return null
|
|
768
805
|
// root EVERYTHING at the SESSION's worktree — readings, freshness, AND the spec tree itself. The
|
|
769
806
|
// worktree's .spec is the branch's pending proposal ([[source-of-truth]]): a node the branch ADDED
|
|
770
807
|
// exists only there, so a trunk-rooted loadSpecs would silently drop it from the model (the 0fca
|
|
771
808
|
// family's node-existence layer). No worktree → the backend checkout, unchanged.
|
|
772
|
-
const wtPath = worktreePathForBranch(
|
|
809
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
773
810
|
const ctxRoot = wtPath ?? repoRoot()
|
|
811
|
+
return withEventLedgerDemand(ctxRoot, async () => {
|
|
812
|
+
const payload = await reviewPayload(id)
|
|
813
|
+
return payload
|
|
814
|
+
? withEventLedgerBuild(ctxRoot, () => buildExportModelInLedger(id, payload, wtPath, ctxRoot))
|
|
815
|
+
: null
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function buildExportModelInLedger(id: string, payload: ReviewPayloadValue, wtPath: string | null, ctxRoot: string): Promise<ExportModel> {
|
|
774
820
|
const specs = await loadSpecs(ctxRoot)
|
|
775
821
|
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
776
822
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
@@ -1383,13 +1429,18 @@ export type SessionEvals = {
|
|
|
1383
1429
|
node: string | null
|
|
1384
1430
|
branch: string | null
|
|
1385
1431
|
title: string
|
|
1386
|
-
ahead: number
|
|
1387
|
-
dirtyNonRuntime: number
|
|
1388
|
-
gates: ExportGate[]
|
|
1389
1432
|
nodes: SessionEvalNode[]
|
|
1390
1433
|
impact: SessionImpactProjection
|
|
1391
|
-
|
|
1434
|
+
// the whole scope's seven counts. A FOCUSED build cannot honestly produce them — it measured six rows,
|
|
1435
|
+
// not the scope — so it carries the summary only when the cut already holds one, and otherwise omits it
|
|
1436
|
+
// rather than publishing a fold over the window. `evalRevision.content` is the stronger identity anyway:
|
|
1437
|
+
// equal content revision IS the same evaluation cut, so a consumer fencing on it needs no counts.
|
|
1438
|
+
summary?: SessionEvalSummary
|
|
1392
1439
|
evalRevision: SessionEvalRevision
|
|
1440
|
+
// present ONLY on a focused build: the whole population's identity+sequence facts, because `nodes` then
|
|
1441
|
+
// holds just the few the response will render. Its presence is exactly what marks a model as PARTIAL —
|
|
1442
|
+
// such a model may never enter the shared cut, or the list page would read the scope as those few nodes.
|
|
1443
|
+
order?: SessionEvalOrderRow[]
|
|
1393
1444
|
}
|
|
1394
1445
|
|
|
1395
1446
|
export type SessionEvalSummary = {
|
|
@@ -1449,7 +1500,7 @@ async function sessionScopeNodes(
|
|
|
1449
1500
|
ctx: Awaited<ReturnType<typeof evalContext>>,
|
|
1450
1501
|
impact: SessionImpactProjection,
|
|
1451
1502
|
shas: ReadonlySet<string>,
|
|
1452
|
-
|
|
1503
|
+
freshness: { order?: boolean; only?: ReadonlySet<string> } = {},
|
|
1453
1504
|
): Promise<SessionEvalNode[]> {
|
|
1454
1505
|
const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
|
|
1455
1506
|
const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
|
|
@@ -1460,10 +1511,13 @@ async function sessionScopeNodes(
|
|
|
1460
1511
|
// instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
|
|
1461
1512
|
const timelineIds = impact.nodes
|
|
1462
1513
|
.filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
|
|
1514
|
+
.filter((projected) => !freshness.only || freshness.only.has(projected.id))
|
|
1463
1515
|
.map((projected) => projected.id)
|
|
1464
|
-
const timelineById = new Map((await evalTimelines(timelineIds, ctx
|
|
1516
|
+
const timelineById = new Map((await evalTimelines(timelineIds, ctx, { order: freshness.order }))
|
|
1517
|
+
.map((timeline, i) => [timelineIds[i], timeline]))
|
|
1465
1518
|
|
|
1466
1519
|
for (const projected of impact.nodes) {
|
|
1520
|
+
if (freshness.only && !freshness.only.has(projected.id)) continue
|
|
1467
1521
|
const spec = specById.get(projected.id)
|
|
1468
1522
|
if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
|
|
1469
1523
|
const evalNode = evalById.get(spec.id)
|
|
@@ -1507,7 +1561,7 @@ async function sessionScopeNodes(
|
|
|
1507
1561
|
scenarios: scoped.scenarios,
|
|
1508
1562
|
// Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
|
|
1509
1563
|
// honest downstream states; impact selection never removes a row because its reading is stale.
|
|
1510
|
-
evals:
|
|
1564
|
+
evals: scoped.evals,
|
|
1511
1565
|
})
|
|
1512
1566
|
}
|
|
1513
1567
|
|
|
@@ -1570,35 +1624,71 @@ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
|
|
|
1570
1624
|
|
|
1571
1625
|
async function buildSessionEvalModel(
|
|
1572
1626
|
id: string,
|
|
1573
|
-
|
|
1627
|
+
identity: ReviewIdentity,
|
|
1574
1628
|
wtPath: string | null,
|
|
1575
|
-
|
|
1629
|
+
pick?: SessionEvalFocus,
|
|
1576
1630
|
): Promise<SessionEvalModel> {
|
|
1577
1631
|
// spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
|
|
1578
1632
|
// in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
|
|
1579
1633
|
const ctxRoot = wtPath ?? repoRoot()
|
|
1634
|
+
return withEventLedgerBuild(ctxRoot, () => buildSessionEvalModelInLedger(id, identity, wtPath, pick, ctxRoot))
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function buildSessionEvalModelInLedger(
|
|
1638
|
+
id: string,
|
|
1639
|
+
identity: ReviewIdentity,
|
|
1640
|
+
wtPath: string | null,
|
|
1641
|
+
pick: SessionEvalFocus | undefined,
|
|
1642
|
+
ctxRoot: string,
|
|
1643
|
+
): Promise<SessionEvalModel> {
|
|
1580
1644
|
const specs = await loadSpecs(ctxRoot)
|
|
1581
1645
|
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
1582
1646
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
1583
1647
|
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
1584
1648
|
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
|
|
1585
|
-
|
|
1649
|
+
// @@@ TWO passes, ONE context - a detail open renders one scenario and at most five neighbours, but it
|
|
1650
|
+
// still owes the full population's sequence (its `index` and `total`). Sequence is freshness-free, so the
|
|
1651
|
+
// first pass reads every node's rows with no probes at all (~0.4s of the 25s), the caller names the few
|
|
1652
|
+
// nodes whose verdicts will actually be published, and only those pay the freshness pass. The context and
|
|
1653
|
+
// the impact projection are built once and shared, so the cheap pass adds no second projection.
|
|
1654
|
+
let order: SessionEvalOrderRow[] | undefined
|
|
1655
|
+
let only: ReadonlySet<string> | undefined
|
|
1656
|
+
if (pick) {
|
|
1657
|
+
const draft = await sessionScopeNodes(id, ctx, impact, shas, { order: true })
|
|
1658
|
+
order = orderRowsOf(draft)
|
|
1659
|
+
only = new Set(pick(order))
|
|
1660
|
+
}
|
|
1661
|
+
const nodes = await sessionScopeNodes(id, ctx, impact, shas, only ? { only } : {})
|
|
1586
1662
|
// nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
|
|
1587
1663
|
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
|
|
1588
1664
|
|| (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
|
|
1589
1665
|
|
|
1590
|
-
const primary =
|
|
1666
|
+
const primary = identity.node && specById.has(identity.node) ? specById.get(identity.node)!.title : null
|
|
1591
1667
|
return {
|
|
1592
1668
|
id,
|
|
1593
|
-
node:
|
|
1594
|
-
branch:
|
|
1595
|
-
title: primary ||
|
|
1596
|
-
ahead: payload.ahead,
|
|
1597
|
-
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
1598
|
-
gates: gateRows(payload),
|
|
1669
|
+
node: identity.node,
|
|
1670
|
+
branch: identity.branch,
|
|
1671
|
+
title: primary || identity.node || identity.branch || id.slice(0, 8),
|
|
1599
1672
|
nodes,
|
|
1600
1673
|
impact,
|
|
1674
|
+
...(order ? { order } : {}),
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// the identity+sequence facts a detail open needs about scenarios it will NOT render: which exist, whether
|
|
1679
|
+
// they carry a filed reading, and when. Deliberately not an EvalEntry — there is no verdict here to leak.
|
|
1680
|
+
export type SessionEvalOrderRow = { node: string; scenario: string; ts: string | null }
|
|
1681
|
+
export type SessionEvalFocus = (order: SessionEvalOrderRow[]) => readonly string[]
|
|
1682
|
+
|
|
1683
|
+
export function orderRowsOf(nodes: SessionEvalNode[]): SessionEvalOrderRow[] {
|
|
1684
|
+
const rows: SessionEvalOrderRow[] = []
|
|
1685
|
+
for (const node of nodes) {
|
|
1686
|
+
const latest = new Map<string, string>()
|
|
1687
|
+
for (const reading of node.evals) if (!latest.has(reading.scenario)) latest.set(reading.scenario, reading.ts)
|
|
1688
|
+
for (const scenario of node.scenarios)
|
|
1689
|
+
rows.push({ node: node.id, scenario: scenario.name, ts: latest.get(scenario.name) ?? null })
|
|
1601
1690
|
}
|
|
1691
|
+
return rows
|
|
1602
1692
|
}
|
|
1603
1693
|
|
|
1604
1694
|
function untrackedPaths(status: string): string[] {
|
|
@@ -1652,13 +1742,6 @@ type SummaryBuildResult =
|
|
|
1652
1742
|
|
|
1653
1743
|
export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
|
|
1654
1744
|
|
|
1655
|
-
type DemandJob<T> = {
|
|
1656
|
-
id: string
|
|
1657
|
-
run: () => Promise<T>
|
|
1658
|
-
resolve: (value: T) => void
|
|
1659
|
-
reject: (error: unknown) => void
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
1745
|
type ProjectionEntry = {
|
|
1663
1746
|
id: string
|
|
1664
1747
|
path: string
|
|
@@ -1668,6 +1751,7 @@ type ProjectionEntry = {
|
|
|
1668
1751
|
current?: { generation: number; revision: string; value: SessionEvalSummary }
|
|
1669
1752
|
scheduled: number | null
|
|
1670
1753
|
running: number | null
|
|
1754
|
+
flight: Promise<void> | null
|
|
1671
1755
|
demandCancelledGeneration: number | null
|
|
1672
1756
|
observerHolds: Set<string>
|
|
1673
1757
|
}
|
|
@@ -1682,27 +1766,16 @@ type ProjectionCohortRow = Pick<StagedProjection, 'entry' | 'generation'>
|
|
|
1682
1766
|
|
|
1683
1767
|
type ProjectionTarget = 'all' | { id?: string; path?: string }
|
|
1684
1768
|
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1687
|
-
// projection queue has one bounded capacity for every project. A deployment can tune the capacity, but the
|
|
1688
|
-
// default is deliberately serial: board assembly remains responsive and memory has a natural settle point.
|
|
1689
|
-
const configuredProjectionConcurrency = Number(process.env.SPEXCODE_SESSION_EVAL_CONCURRENCY || 1)
|
|
1690
|
-
const PROJECTION_CONCURRENCY = Number.isFinite(configuredProjectionConcurrency)
|
|
1691
|
-
? Math.max(1, Math.floor(configuredProjectionConcurrency))
|
|
1692
|
-
: 1
|
|
1693
|
-
|
|
1694
|
-
// Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
|
|
1695
|
-
// entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
|
|
1696
|
-
// `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
|
|
1769
|
+
// Pure generation coordinator around an injected stable builder. Sessions have independent evaluation cuts,
|
|
1770
|
+
// so an entry launches and publishes independently; generation/revision fences remain per entry.
|
|
1697
1771
|
export class SessionEvalProjectionCache {
|
|
1698
1772
|
readonly epoch: string
|
|
1699
1773
|
private readonly entries = new Map<string, ProjectionEntry>()
|
|
1700
1774
|
private readonly observerHolds = new Map<string, ProjectionTarget>()
|
|
1701
1775
|
private readonly observerWaiters = new Set<() => void>()
|
|
1702
|
-
private
|
|
1776
|
+
private readonly flights = new Set<Promise<void>>()
|
|
1703
1777
|
private notify: () => void
|
|
1704
1778
|
private precompute: boolean
|
|
1705
|
-
private readonly demandQueue: DemandJob<any>[] = []
|
|
1706
1779
|
private readonly demands = new Map<string, Promise<unknown>>()
|
|
1707
1780
|
|
|
1708
1781
|
constructor(
|
|
@@ -1730,7 +1803,7 @@ export class SessionEvalProjectionCache {
|
|
|
1730
1803
|
}
|
|
1731
1804
|
this.authorize(entry)
|
|
1732
1805
|
}
|
|
1733
|
-
if (enabled) queueMicrotask(() => this.
|
|
1806
|
+
if (enabled) queueMicrotask(() => this.startScheduled())
|
|
1734
1807
|
}
|
|
1735
1808
|
|
|
1736
1809
|
private authorize(entry: ProjectionEntry): void {
|
|
@@ -1755,6 +1828,7 @@ export class SessionEvalProjectionCache {
|
|
|
1755
1828
|
phase: 'loading',
|
|
1756
1829
|
scheduled: null,
|
|
1757
1830
|
running: null,
|
|
1831
|
+
flight: null,
|
|
1758
1832
|
demandCancelledGeneration: null,
|
|
1759
1833
|
observerHolds: new Set(),
|
|
1760
1834
|
}
|
|
@@ -1778,6 +1852,7 @@ export class SessionEvalProjectionCache {
|
|
|
1778
1852
|
phase: 'loading',
|
|
1779
1853
|
scheduled: null,
|
|
1780
1854
|
running: null,
|
|
1855
|
+
flight: null,
|
|
1781
1856
|
demandCancelledGeneration: null,
|
|
1782
1857
|
observerHolds: new Set(),
|
|
1783
1858
|
}
|
|
@@ -1797,7 +1872,7 @@ export class SessionEvalProjectionCache {
|
|
|
1797
1872
|
this.authorize(entry)
|
|
1798
1873
|
out.set(session.id, this.project(entry))
|
|
1799
1874
|
}
|
|
1800
|
-
queueMicrotask(() => this.
|
|
1875
|
+
queueMicrotask(() => this.startScheduled())
|
|
1801
1876
|
return out
|
|
1802
1877
|
}
|
|
1803
1878
|
|
|
@@ -1875,28 +1950,31 @@ export class SessionEvalProjectionCache {
|
|
|
1875
1950
|
const existing = this.demands.get(id)
|
|
1876
1951
|
if (existing) return existing as Promise<T>
|
|
1877
1952
|
const entry = this.ensureEntry(id, path)
|
|
1878
|
-
//
|
|
1879
|
-
//
|
|
1880
|
-
// A running summary is left alone; the priority job waits for it to settle before taking the slot.
|
|
1953
|
+
// An unstarted summary is superseded by the full demand build. A running summary stays the one flight for
|
|
1954
|
+
// this entry; demand waits for it only, never for unrelated session work.
|
|
1881
1955
|
if (entry.running == null && entry.scheduled === entry.generation) {
|
|
1882
1956
|
entry.scheduled = null
|
|
1883
1957
|
entry.demandCancelledGeneration = entry.generation
|
|
1884
1958
|
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1959
|
+
const priorFlight = entry.flight
|
|
1960
|
+
const promise = (async () => {
|
|
1961
|
+
if (priorFlight) await priorFlight
|
|
1962
|
+
return run()
|
|
1963
|
+
})()
|
|
1888
1964
|
this.demands.set(id, promise)
|
|
1889
|
-
this.demandQueue.push({ id, run, resolve, reject })
|
|
1890
1965
|
promise.finally(() => {
|
|
1891
1966
|
if (this.demands.get(id) === promise) this.demands.delete(id)
|
|
1892
1967
|
}).catch(() => {})
|
|
1893
|
-
queueMicrotask(() => this.startBatch())
|
|
1894
1968
|
return promise
|
|
1895
1969
|
}
|
|
1896
1970
|
|
|
1897
1971
|
async idle(): Promise<void> {
|
|
1898
|
-
|
|
1899
|
-
|
|
1972
|
+
for (;;) {
|
|
1973
|
+
await Promise.resolve()
|
|
1974
|
+
const pending = [...this.flights, ...this.demands.values()]
|
|
1975
|
+
if (!pending.length) return
|
|
1976
|
+
await Promise.allSettled(pending)
|
|
1977
|
+
}
|
|
1900
1978
|
}
|
|
1901
1979
|
|
|
1902
1980
|
accept(id: string, generation: number, revision: string, value: SessionEvalSummary): boolean {
|
|
@@ -1926,64 +2004,57 @@ export class SessionEvalProjectionCache {
|
|
|
1926
2004
|
return target === 'all' || target.id === entry.id || target.path === entry.path
|
|
1927
2005
|
}
|
|
1928
2006
|
|
|
1929
|
-
private
|
|
1930
|
-
return this.demandQueue.length > 0
|
|
1931
|
-
|| [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
private publishCohort(staged: StagedProjection[]): boolean {
|
|
2007
|
+
private publish(staged: StagedProjection): boolean {
|
|
1935
2008
|
let changed = false
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
entry.scheduled = null
|
|
1942
|
-
entry.demandCancelledGeneration = null
|
|
1943
|
-
this.authorize(entry)
|
|
1944
|
-
changed = true
|
|
1945
|
-
continue
|
|
1946
|
-
}
|
|
1947
|
-
if (result.kind === 'missing') {
|
|
1948
|
-
changed = changed || entry.phase !== 'error'
|
|
1949
|
-
entry.phase = 'error'
|
|
1950
|
-
entry.scheduled = null
|
|
1951
|
-
continue
|
|
1952
|
-
}
|
|
1953
|
-
changed = changed || entry.phase !== 'ready' || entry.current?.revision !== result.revision
|
|
1954
|
-
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
1955
|
-
entry.phase = 'ready'
|
|
2009
|
+
const { entry, generation, result } = staged
|
|
2010
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) return false
|
|
2011
|
+
if (result.kind === 'unstable') {
|
|
2012
|
+
entry.generation++
|
|
2013
|
+
entry.phase = 'updating'
|
|
1956
2014
|
entry.scheduled = null
|
|
2015
|
+
entry.demandCancelledGeneration = null
|
|
2016
|
+
this.authorize(entry)
|
|
2017
|
+
return true
|
|
1957
2018
|
}
|
|
2019
|
+
if (result.kind === 'missing') {
|
|
2020
|
+
changed = entry.phase !== 'error'
|
|
2021
|
+
entry.phase = 'error'
|
|
2022
|
+
entry.scheduled = null
|
|
2023
|
+
return changed
|
|
2024
|
+
}
|
|
2025
|
+
changed = entry.phase !== 'ready' || entry.current?.revision !== result.revision
|
|
2026
|
+
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
2027
|
+
entry.phase = 'ready'
|
|
2028
|
+
entry.scheduled = null
|
|
1958
2029
|
return changed
|
|
1959
2030
|
}
|
|
1960
2031
|
|
|
1961
|
-
private
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
// Freeze this finite cohort. Inputs that arrive later remain scheduled for the next batch, so a
|
|
1972
|
-
// busy stream cannot keep a completed cohort unpublished forever.
|
|
1973
|
-
const cohort = [...this.entries.values()]
|
|
1974
|
-
.filter((entry) => entry.scheduled != null && entry.running == null)
|
|
1975
|
-
.map((entry) => ({ entry, generation: entry.scheduled! }))
|
|
1976
|
-
const staged: StagedProjection[] = []
|
|
1977
|
-
for (let offset = 0; offset < cohort.length && !this.demandQueue.length; offset += PROJECTION_CONCURRENCY) {
|
|
1978
|
-
const chunk = cohort.slice(offset, offset + PROJECTION_CONCURRENCY)
|
|
1979
|
-
const results = await Promise.all(chunk.map((row) => this.runEntry(row)))
|
|
1980
|
-
for (const result of results) if (result) staged.push(result)
|
|
1981
|
-
}
|
|
1982
|
-
if (this.publishCohort(staged)) this.notify()
|
|
1983
|
-
})().finally(() => {
|
|
1984
|
-
this.batch = null
|
|
1985
|
-
if (this.hasPending()) this.startBatch()
|
|
2032
|
+
private startScheduled(): void {
|
|
2033
|
+
for (const entry of this.entries.values()) {
|
|
2034
|
+
if (entry.scheduled == null || entry.running != null) continue
|
|
2035
|
+
this.startEntry({ entry, generation: entry.scheduled })
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
private startEntry(row: ProjectionCohortRow): void {
|
|
2040
|
+
const flight = this.runEntry(row).then((staged) => {
|
|
2041
|
+
if (staged && this.publish(staged)) this.notify()
|
|
1986
2042
|
})
|
|
2043
|
+
row.entry.flight = flight
|
|
2044
|
+
this.flights.add(flight)
|
|
2045
|
+
void flight.then(
|
|
2046
|
+
() => {
|
|
2047
|
+
this.flights.delete(flight)
|
|
2048
|
+
if (row.entry.flight === flight) row.entry.flight = null
|
|
2049
|
+
queueMicrotask(() => this.startScheduled())
|
|
2050
|
+
},
|
|
2051
|
+
(error) => {
|
|
2052
|
+
this.flights.delete(flight)
|
|
2053
|
+
if (row.entry.flight === flight) row.entry.flight = null
|
|
2054
|
+
console.warn(`spec-eval: session summary runner failed for ${row.entry.id}: ${error instanceof Error ? error.message : String(error)}`)
|
|
2055
|
+
queueMicrotask(() => this.startScheduled())
|
|
2056
|
+
},
|
|
2057
|
+
)
|
|
1987
2058
|
}
|
|
1988
2059
|
|
|
1989
2060
|
private async runEntry({ entry, generation }: ProjectionCohortRow): Promise<StagedProjection | null> {
|
|
@@ -2004,35 +2075,41 @@ export class SessionEvalProjectionCache {
|
|
|
2004
2075
|
}
|
|
2005
2076
|
|
|
2006
2077
|
async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBuildResult> {
|
|
2007
|
-
const
|
|
2008
|
-
if (!
|
|
2009
|
-
const wtPath = worktreePathForBranch(
|
|
2078
|
+
const identity = reviewIdentity(id)
|
|
2079
|
+
if (!identity) return { kind: 'missing' }
|
|
2080
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
2010
2081
|
const ctxPath = wtPath ?? repoRoot()
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2082
|
+
return withEventLedgerDemand(ctxPath, async () => {
|
|
2083
|
+
const before = await sessionEvalContentRevision(ctxPath)
|
|
2084
|
+
const cacheKey = `${id}\0${before}`
|
|
2085
|
+
const cached = summaryByContent.get(cacheKey)
|
|
2086
|
+
if (cached) {
|
|
2087
|
+
const after = await sessionEvalContentRevision(ctxPath)
|
|
2088
|
+
return before === after
|
|
2089
|
+
? { kind: 'stable', revision: after, summary: cached.summary }
|
|
2090
|
+
: { kind: 'unstable' }
|
|
2091
|
+
}
|
|
2092
|
+
const model = await buildSessionEvalModel(id, identity, wtPath)
|
|
2015
2093
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
//
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
// to a demand would silently truncate every scenario's history.
|
|
2094
|
+
if (before !== after) return { kind: 'unstable' }
|
|
2095
|
+
const summary = sessionEvalSummary(model.nodes)
|
|
2096
|
+
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
2097
|
+
// This fold IS the demand's fold, so it deposits the model too and a later open replays it.
|
|
2098
|
+
depositStableCut(id, after, { summary, model })
|
|
2099
|
+
return { kind: 'stable', revision: after, summary }
|
|
2100
|
+
})
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// ONE content-addressed cut per session, keyed by id + content revision, carrying the summary the graph
|
|
2104
|
+
// reads and the full model a repeat open replays.
|
|
2105
|
+
// @@@one builder, not two - these used to be separate folds: the graph's built latestOnly (latest reading
|
|
2106
|
+
// per scenario) and deposited counts alone, so the demand path re-derived the WHOLE thing to get history
|
|
2107
|
+
// the graph fold had held one line earlier and thrown away. The trim never made the fold cheaper — the
|
|
2108
|
+
// expensive part is the freshness pass over every node in scope, and it ran identically either way — and
|
|
2109
|
+
// it never mattered to the counts, because sessionEvalSummary folds latestPerScenario itself. So the trim
|
|
2110
|
+
// bought nothing and cost a second full build per session per revision. The price of dropping it is
|
|
2111
|
+
// MEMORY: a cut now retains complete A/B history rather than latest-per-scenario, bounded by the one-cut-
|
|
2112
|
+
// per-session rule below.
|
|
2036
2113
|
type StableCut = { summary: SessionEvalSummary; model?: SessionEvalModel }
|
|
2037
2114
|
const summaryByContent = new Map<string, StableCut>()
|
|
2038
2115
|
|
|
@@ -2078,15 +2155,19 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
|
|
|
2078
2155
|
}
|
|
2079
2156
|
export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
|
|
2080
2157
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2158
|
+
// `pick` makes this a FOCUSED build: the caller names, from the population's sequence, the few nodes whose
|
|
2159
|
+
// verdicts its response will publish, and only those pay the freshness pass. A focused build is PARTIAL, so
|
|
2160
|
+
// it never deposits — but it still prefers a cached FULL model when one exists, because a complete answer
|
|
2161
|
+
// already paid for beats a cheap incomplete one.
|
|
2162
|
+
export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Promise<SessionEvals | null> {
|
|
2163
|
+
// A full model is demand-only. It joins its session's current summary flight, but unrelated summaries never
|
|
2164
|
+
// gate this demand; a cached stable cut then replays without re-deriving Git state.
|
|
2165
|
+
const identity = reviewIdentity(id)
|
|
2166
|
+
if (!identity) return null
|
|
2167
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
2168
|
+
const ctxPath = wtPath ?? repoRoot()
|
|
2084
2169
|
for (;;) {
|
|
2085
|
-
const attempt = await projectionCache.demand(id, '', async () => {
|
|
2086
|
-
const payload = await reviewPayload(id)
|
|
2087
|
-
if (!payload) return { kind: 'missing' as const }
|
|
2088
|
-
const wtPath = worktreePathForBranch(payload.branch)
|
|
2089
|
-
const ctxPath = wtPath ?? repoRoot()
|
|
2170
|
+
const attempt = await projectionCache.demand(id, '', () => withEventLedgerDemand(ctxPath, async () => {
|
|
2090
2171
|
const known = projectionCache.get(id)
|
|
2091
2172
|
const generation = known?.generation ?? 0
|
|
2092
2173
|
await awaitObservableInputs(id, ctxPath)
|
|
@@ -2108,7 +2189,7 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2108
2189
|
if (before !== settled) projectionCache.invalidate({ id })
|
|
2109
2190
|
return { kind: 'retry' as const }
|
|
2110
2191
|
}
|
|
2111
|
-
const model = await buildSessionEvalModel(id,
|
|
2192
|
+
const model = await buildSessionEvalModel(id, identity, wtPath, pick)
|
|
2112
2193
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2113
2194
|
const current = projectionCache.get(id)
|
|
2114
2195
|
if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
|
|
@@ -2116,18 +2197,23 @@ export async function buildSessionEvals(id: string): Promise<SessionEvals | null
|
|
|
2116
2197
|
if (before !== after) projectionCache.invalidate({ id })
|
|
2117
2198
|
return { kind: 'retry' as const }
|
|
2118
2199
|
}
|
|
2200
|
+
// A focused model holds only the nodes it was asked to publish, so its counts are NOT the scope's and
|
|
2201
|
+
// its nodes are NOT the scope's. Neither may be deposited or accepted: doing so would tell the list
|
|
2202
|
+
// page and the graph that the session's whole evaluation is those few nodes.
|
|
2203
|
+
if (model.order)
|
|
2204
|
+
return { kind: 'ready' as const, model, summary: known?.value, generation, revision: after }
|
|
2119
2205
|
const summary = sessionEvalSummary(model.nodes)
|
|
2120
2206
|
// Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
|
|
2121
2207
|
// neither line, so a failure can never poison the entry.
|
|
2122
2208
|
depositStableCut(id, after, { summary, model })
|
|
2123
2209
|
if (current) projectionCache.accept(id, generation, after, summary)
|
|
2124
2210
|
return { kind: 'ready' as const, model, summary, generation, revision: after }
|
|
2125
|
-
})
|
|
2126
|
-
if (attempt.kind === 'missing') return null
|
|
2211
|
+
}))
|
|
2127
2212
|
if (attempt.kind === 'retry') continue
|
|
2128
2213
|
return {
|
|
2129
2214
|
...attempt.model,
|
|
2130
|
-
summary
|
|
2215
|
+
// absent on a focused build whose cut holds no summary — never a fold over the rendered window
|
|
2216
|
+
...(attempt.summary ? { summary: attempt.summary } : {}),
|
|
2131
2217
|
evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
|
|
2132
2218
|
}
|
|
2133
2219
|
}
|