mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-25 08:48:08 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
168
packages/context/src/ingest/memory-flow/acceptance-fixtures.ts
Normal file
168
packages/context/src/ingest/memory-flow/acceptance-fixtures.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
|
||||
function baseScenario(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'run-success',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'metricflow',
|
||||
status: 'done',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-success',
|
||||
reportPath: 'ingest-report.json',
|
||||
errors: [],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 4 },
|
||||
{ type: 'scope_detected', fingerprint: 'metricflow:demo' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-success', rawFileCount: 4 },
|
||||
{ type: 'diff_computed', added: 2, modified: 1, deleted: 0, unchanged: 1 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'wiki', action: 'created', key: 'knowledge/global/orders.md' },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'sl', action: 'updated', key: 'warehouse.orders' },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'work_unit_started', unitKey: 'revenue', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'revenue', target: 'wiki', action: 'updated', key: 'knowledge/global/revenue.md' },
|
||||
{ type: 'work_unit_finished', unitKey: 'revenue', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'abc123456789', wikiCount: 2, slCount: 1 }, // pragma: allowlist secret
|
||||
{ type: 'provenance_recorded', rowCount: 4 },
|
||||
{ type: 'report_created', runId: 'run-success', reportPath: 'ingest-report.json' },
|
||||
],
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'orders', rawFiles: ['models/orders.yml', 'models/customers.yml'], peerFileCount: 1, dependencyCount: 1 },
|
||||
{ unitKey: 'revenue', rawFiles: ['docs/revenue.md'], peerFileCount: 0, dependencyCount: 0 },
|
||||
],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/orders.md',
|
||||
summary: 'Captured order definitions',
|
||||
rawFiles: ['models/orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'sl',
|
||||
action: 'updated',
|
||||
key: 'warehouse.orders',
|
||||
summary: 'Updated orders source',
|
||||
rawFiles: ['models/orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue',
|
||||
target: 'wiki',
|
||||
action: 'updated',
|
||||
key: 'knowledge/global/revenue.md',
|
||||
summary: 'Updated revenue notes',
|
||||
rawFiles: ['docs/revenue.md'],
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
provenance: [
|
||||
{
|
||||
rawPath: 'models/orders.yml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/global/orders.md',
|
||||
actionType: 'created',
|
||||
},
|
||||
{ rawPath: 'models/orders.yml', artifactKind: 'sl', artifactKey: 'warehouse.orders', actionType: 'updated' },
|
||||
],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: 'transcripts/orders.json',
|
||||
toolCallCount: 3,
|
||||
errorCount: 0,
|
||||
toolNames: ['wiki_write', 'sl_write_source'],
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function successfulReplayScenario(): MemoryFlowReplayInput {
|
||||
return baseScenario();
|
||||
}
|
||||
|
||||
export function deletedRawPathsScenario(): MemoryFlowReplayInput {
|
||||
return baseScenario({
|
||||
events: baseScenario().events.map((event) =>
|
||||
event.type === 'diff_computed'
|
||||
? { ...event, deleted: 2 }
|
||||
: event.type === 'chunks_planned'
|
||||
? { ...event, evictionCount: 2 }
|
||||
: event,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function validationRevertScenario(): MemoryFlowReplayInput {
|
||||
return baseScenario({
|
||||
runId: 'run-validation-failure',
|
||||
status: 'error',
|
||||
errors: ['semantic-layer validation failed for warehouse.orders'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 1 },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-validation', rawFileCount: 1 },
|
||||
{ type: 'diff_computed', added: 1, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'sl', action: 'updated', key: 'warehouse.orders' },
|
||||
{
|
||||
type: 'work_unit_finished',
|
||||
unitKey: 'orders',
|
||||
status: 'failed',
|
||||
reason: 'semantic-layer validation failed for warehouse.orders',
|
||||
},
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['models/orders.yml'], peerFileCount: 0, dependencyCount: 0 }],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'sl',
|
||||
action: 'updated',
|
||||
key: 'warehouse.orders',
|
||||
summary: 'Invalid measure was reverted',
|
||||
rawFiles: ['models/orders.yml'],
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
provenance: [],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: 'transcripts/orders.json',
|
||||
toolCallCount: 2,
|
||||
errorCount: 1,
|
||||
toolNames: ['sl_write_source'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function flaggedFallbackScenario(): MemoryFlowReplayInput {
|
||||
return baseScenario({
|
||||
runId: 'run-flagged-fallback',
|
||||
events: baseScenario().events.map((event) =>
|
||||
event.type === 'reconciliation_finished' ? { ...event, fallbackCount: 1 } : event,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function postSaveSecretFailureScenario(): MemoryFlowReplayInput {
|
||||
return baseScenario({
|
||||
runId: 'run-post-save-failure',
|
||||
status: 'error',
|
||||
errors: ['index refresh failed https://example.com/private token=abc123'],
|
||||
events: baseScenario().events.map((event) =>
|
||||
event.type === 'saved' ? { ...event, commitSha: 'def456789012' } : event, // pragma: allowlist secret
|
||||
),
|
||||
});
|
||||
}
|
||||
62
packages/context/src/ingest/memory-flow/acceptance.test.ts
Normal file
62
packages/context/src/ingest/memory-flow/acceptance.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deletedRawPathsScenario,
|
||||
flaggedFallbackScenario,
|
||||
postSaveSecretFailureScenario,
|
||||
successfulReplayScenario,
|
||||
validationRevertScenario,
|
||||
} from './acceptance-fixtures.js';
|
||||
import { renderMemoryFlowReplay } from './render.js';
|
||||
import { buildMemoryFlowViewModel } from './view-model.js';
|
||||
|
||||
function renderScenario(input = successfulReplayScenario(), terminalWidth = 140): string {
|
||||
return renderMemoryFlowReplay(buildMemoryFlowViewModel(input), { terminalWidth });
|
||||
}
|
||||
|
||||
describe('memory-flow acceptance scenarios', () => {
|
||||
it('renders a completed replay with a clear saved-memory completion line', () => {
|
||||
const output = renderScenario(successfulReplayScenario());
|
||||
|
||||
expect(output).toContain('KLO memory flow warehouse/metricflow done');
|
||||
expect(output).toContain('Saved 3 memories from 4 raw files: 2 wiki pages, 1 SL updates.');
|
||||
expect(output).toContain('Commit: abc12345 Run: run-success Report: ingest-report.json');
|
||||
});
|
||||
|
||||
it('renders deleted raw paths as eviction candidates without listing every raw path by default', () => {
|
||||
const output = renderScenario(deletedRawPathsScenario());
|
||||
|
||||
expect(output).toContain('2 deletions');
|
||||
expect(output).toContain('Eviction candidates: 2');
|
||||
expect(output).not.toContain('/full/local/path/private/orders-2024.sql');
|
||||
});
|
||||
|
||||
it('renders invalid semantic-layer writes as reverted, not saved', () => {
|
||||
const output = renderScenario(validationRevertScenario());
|
||||
|
||||
expect(output).toContain('orders reverted: semantic-layer validation failed for warehouse.orders');
|
||||
expect(output).toContain('Invalid semantic-layer writes were not saved.');
|
||||
expect(output).not.toContain('Saved 1 memories');
|
||||
});
|
||||
|
||||
it('renders flagged fallbacks in gates details', () => {
|
||||
const output = renderScenario(flaggedFallbackScenario());
|
||||
|
||||
expect(output).toContain('0 conflict, 1 fallback');
|
||||
expect(output).toContain('Flagged fallbacks: 1');
|
||||
});
|
||||
|
||||
it('renders no ANSI color codes in the text fallback for terminals without color support', () => {
|
||||
const output = renderScenario(successfulReplayScenario(), 80);
|
||||
|
||||
expect(output).toContain('KLO memory flow warehouse/metricflow done');
|
||||
expect(output).not.toMatch(/\u001b\[[0-9;]*m/);
|
||||
});
|
||||
|
||||
it('redacts secrets in visible post-save failure text', () => {
|
||||
const output = renderScenario(postSaveSecretFailureScenario());
|
||||
|
||||
expect(output).toContain('Post-save error: index refresh failed https://[redacted] token=[redacted]');
|
||||
expect(output).not.toContain('abc123');
|
||||
expect(output).not.toContain('https://example.com/private');
|
||||
});
|
||||
});
|
||||
332
packages/context/src/ingest/memory-flow/events.test.ts
Normal file
332
packages/context/src/ingest/memory-flow/events.test.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { LocalIngestRunRecord } from '../local-stage-ingest.js';
|
||||
import type { IngestReportSnapshot } from '../reports.js';
|
||||
import { ingestReportToMemoryFlowReplay, localIngestRunToMemoryFlowReplay } from './events.js';
|
||||
|
||||
function localRecord(): LocalIngestRunRecord {
|
||||
return {
|
||||
runId: 'local-run-1',
|
||||
jobId: 'local-run-1',
|
||||
status: 'done',
|
||||
adapter: 'metricflow',
|
||||
connectionId: 'warehouse',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-1',
|
||||
startedAt: '2026-04-30T10:00:00.000Z',
|
||||
completedAt: '2026-04-30T10:00:01.000Z',
|
||||
progress: 1,
|
||||
done: true,
|
||||
previousRunId: null,
|
||||
diffSummary: { added: 2, modified: 1, deleted: 1, unchanged: 4 },
|
||||
diffPaths: {
|
||||
added: ['models/orders.yml', 'models/revenue.yml'],
|
||||
modified: ['models/customers.yml'],
|
||||
deleted: ['models/old.yml'],
|
||||
unchanged: ['models/a.yml', 'models/b.yml', 'models/c.yml', 'models/d.yml'],
|
||||
},
|
||||
workUnitCount: 2,
|
||||
rawFileCount: 7,
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
rawFiles: ['models/orders.yml'],
|
||||
peerFileIndex: ['models/customers.yml'],
|
||||
dependencyPaths: ['models/base.yml'],
|
||||
},
|
||||
{
|
||||
unitKey: 'revenue',
|
||||
rawFiles: ['models/revenue.yml'],
|
||||
peerFileIndex: [],
|
||||
dependencyPaths: [],
|
||||
},
|
||||
],
|
||||
evictionDeletedRawPaths: ['raw-sources/warehouse/metricflow/sync-1/models/old.yml'],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
function reportSnapshot(): IngestReportSnapshot {
|
||||
return {
|
||||
id: 'report-1',
|
||||
runId: 'run-1',
|
||||
jobId: 'job-1',
|
||||
connectionId: 'warehouse',
|
||||
sourceKey: 'lookml',
|
||||
createdAt: '2026-04-30T10:00:02.000Z',
|
||||
body: {
|
||||
syncId: 'sync-2',
|
||||
diffSummary: { added: 1, modified: 1, deleted: 0, unchanged: 3 },
|
||||
commitSha: 'abc123456789', // pragma: allowlist secret
|
||||
failedWorkUnits: ['customers'],
|
||||
reconciliationSkipped: false,
|
||||
conflictsResolved: [
|
||||
{
|
||||
kind: 'near_duplicate',
|
||||
artifactKey: 'warehouse.orders',
|
||||
detail: 'kept candidate definition',
|
||||
flaggedForHuman: false,
|
||||
},
|
||||
],
|
||||
evictionsApplied: [],
|
||||
unmappedFallbacks: [{ rawPath: 'cards/42.json', reason: 'no_connection_mapping', fallback: 'flagged' }],
|
||||
evictionInputs: [],
|
||||
unresolvedCards: [],
|
||||
supersededBy: null,
|
||||
overrideOf: null,
|
||||
provenanceRows: [
|
||||
{
|
||||
rawPath: 'views/orders.view.lkml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/global/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
{
|
||||
rawPath: 'views/orders.view.lkml',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'warehouse.orders',
|
||||
actionType: 'measure_added',
|
||||
},
|
||||
{
|
||||
rawPath: 'views/customers.view.lkml',
|
||||
artifactKind: null,
|
||||
artifactKey: null,
|
||||
actionType: 'skipped',
|
||||
},
|
||||
],
|
||||
toolTranscripts: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: '/tmp/klo/run/wu-transcripts/job-1/orders.jsonl',
|
||||
toolCallCount: 3,
|
||||
errorCount: 0,
|
||||
toolNames: ['read_raw_span', 'wiki_write', 'sl_write_source'],
|
||||
},
|
||||
{
|
||||
unitKey: 'customers',
|
||||
path: '/tmp/klo/run/wu-transcripts/job-1/customers.jsonl',
|
||||
toolCallCount: 2,
|
||||
errorCount: 1,
|
||||
toolNames: ['read_raw_span', 'sl_write_source'],
|
||||
},
|
||||
],
|
||||
workUnits: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
rawFiles: ['views/orders.view.lkml'],
|
||||
status: 'success',
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'knowledge/global/orders.md', detail: 'order facts' },
|
||||
{ target: 'sl', type: 'updated', key: 'warehouse.orders', detail: 'order measures' },
|
||||
],
|
||||
touchedSlSources: [{ connectionId: 'warehouse', sourceName: 'warehouse.orders' }],
|
||||
},
|
||||
{
|
||||
unitKey: 'customers',
|
||||
rawFiles: ['views/customers.view.lkml'],
|
||||
status: 'failed',
|
||||
reason: 'semantic-layer validation failed',
|
||||
actions: [{ target: 'sl', type: 'created', key: 'warehouse.customers', detail: 'invalid source' }],
|
||||
touchedSlSources: [{ connectionId: 'warehouse', sourceName: 'warehouse.customers' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('memory-flow event mapping', () => {
|
||||
it('maps a local ingest run to source, snapshot, diff, chunk, and report events', () => {
|
||||
const replay = localIngestRunToMemoryFlowReplay(localRecord());
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
runId: 'local-run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'metricflow',
|
||||
status: 'done',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-1',
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'orders', rawFiles: ['models/orders.yml'], peerFileCount: 1, dependencyCount: 1 },
|
||||
{ unitKey: 'revenue', rawFiles: ['models/revenue.yml'], peerFileCount: 0, dependencyCount: 0 },
|
||||
],
|
||||
});
|
||||
expect(replay.events).toEqual([
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 7 },
|
||||
{ type: 'scope_detected', fingerprint: null },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-1', rawFileCount: 7 },
|
||||
{ type: 'diff_computed', added: 2, modified: 1, deleted: 1, unchanged: 4 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 1 },
|
||||
{ type: 'report_created', runId: 'local-run-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps an ingest report snapshot to work-unit, candidate, gate, saved, provenance, and report events', () => {
|
||||
const replay = ingestReportToMemoryFlowReplay(reportSnapshot(), { provenanceRowCount: 5 });
|
||||
|
||||
expect(replay).toMatchObject({
|
||||
runId: 'run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'lookml',
|
||||
status: 'error',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-2',
|
||||
reportId: 'report-1',
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'orders', rawFiles: ['views/orders.view.lkml'], peerFileCount: 0, dependencyCount: 0 },
|
||||
{ unitKey: 'customers', rawFiles: ['views/customers.view.lkml'], peerFileCount: 0, dependencyCount: 0 },
|
||||
],
|
||||
});
|
||||
expect(replay.events).toContainEqual({
|
||||
type: 'candidate_action',
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/orders.md',
|
||||
});
|
||||
expect(replay.events).toContainEqual({
|
||||
type: 'work_unit_finished',
|
||||
unitKey: 'customers',
|
||||
status: 'failed',
|
||||
reason: 'semantic-layer validation failed',
|
||||
});
|
||||
expect(replay.events).toContainEqual({ type: 'reconciliation_finished', conflictCount: 1, fallbackCount: 1 });
|
||||
expect(replay.events).toContainEqual({ type: 'saved', commitSha: 'abc123456789', wikiCount: 1, slCount: 2 }); // pragma: allowlist secret
|
||||
expect(replay.events).toContainEqual({ type: 'provenance_recorded', rowCount: 5 });
|
||||
expect(replay.events).toContainEqual({ type: 'report_created', runId: 'run-1', reportPath: 'report-1' });
|
||||
expect(replay.details.actions).toEqual([
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/orders.md',
|
||||
summary: 'order facts',
|
||||
rawFiles: ['views/orders.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'sl',
|
||||
action: 'updated',
|
||||
key: 'warehouse.orders',
|
||||
summary: 'order measures',
|
||||
rawFiles: ['views/orders.view.lkml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'customers',
|
||||
target: 'sl',
|
||||
action: 'created',
|
||||
key: 'warehouse.customers',
|
||||
summary: 'invalid source',
|
||||
rawFiles: ['views/customers.view.lkml'],
|
||||
status: 'failed',
|
||||
},
|
||||
]);
|
||||
expect(replay.details.provenance).toEqual([
|
||||
{
|
||||
rawPath: 'views/orders.view.lkml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/global/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
{
|
||||
rawPath: 'views/orders.view.lkml',
|
||||
artifactKind: 'sl',
|
||||
artifactKey: 'warehouse.orders',
|
||||
actionType: 'measure_added',
|
||||
},
|
||||
{
|
||||
rawPath: 'views/customers.view.lkml',
|
||||
artifactKind: null,
|
||||
artifactKey: null,
|
||||
actionType: 'skipped',
|
||||
},
|
||||
]);
|
||||
expect(replay.details.transcripts).toEqual([
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: '/tmp/klo/run/wu-transcripts/job-1/orders.jsonl',
|
||||
toolCallCount: 3,
|
||||
errorCount: 0,
|
||||
toolNames: ['read_raw_span', 'wiki_write', 'sl_write_source'],
|
||||
},
|
||||
{
|
||||
unitKey: 'customers',
|
||||
path: '/tmp/klo/run/wu-transcripts/job-1/customers.jsonl',
|
||||
toolCallCount: 2,
|
||||
errorCount: 1,
|
||||
toolNames: ['read_raw_span', 'sl_write_source'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers captured memory-flow snapshots from report bodies', () => {
|
||||
const report = reportSnapshot();
|
||||
Object.assign(report.body, {
|
||||
memoryFlow: {
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: null,
|
||||
sourceReportPath: null,
|
||||
fallbackReason: null,
|
||||
},
|
||||
runId: 'run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'lookml',
|
||||
status: 'running',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-2',
|
||||
errors: [],
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'orders', rawFiles: ['views/orders.view.lkml'], peerFileCount: 1, dependencyCount: 2 },
|
||||
],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
events: [
|
||||
{
|
||||
type: 'source_acquired',
|
||||
adapter: 'lookml',
|
||||
trigger: 'manual_resync',
|
||||
fileCount: 1,
|
||||
emittedAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const replay = ingestReportToMemoryFlowReplay(report);
|
||||
|
||||
expect(replay.metadata).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'report-1',
|
||||
fallbackReason: null,
|
||||
});
|
||||
expect(replay.status).toBe('error');
|
||||
expect(replay.reportId).toBe('report-1');
|
||||
expect(replay.reportPath).toBe('report-1');
|
||||
expect(replay.events[0]).toMatchObject({ type: 'source_acquired', emittedAt: '2026-05-01T10:00:00.000Z' });
|
||||
expect(replay.events).toContainEqual({ type: 'report_created', runId: 'run-1', reportPath: 'report-1' });
|
||||
});
|
||||
|
||||
it('labels reconstructed report replays as synthetic when no captured snapshot exists', () => {
|
||||
const replay = ingestReportToMemoryFlowReplay(reportSnapshot(), { provenanceRowCount: 5 });
|
||||
|
||||
expect(replay.metadata).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'synthetic-report',
|
||||
timing: 'synthetic',
|
||||
capturedAt: '2026-04-30T10:00:02.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'report-1',
|
||||
fallbackReason: 'report did not include captured memory-flow events',
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/context/src/ingest/memory-flow/events.ts
Normal file
247
packages/context/src/ingest/memory-flow/events.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import type { MemoryAction } from '../../memory/index.js';
|
||||
import type { LocalIngestRunRecord } from '../local-stage-ingest.js';
|
||||
import type { IngestReportSnapshot } from '../reports.js';
|
||||
import type {
|
||||
MemoryFlowActionDetail,
|
||||
MemoryFlowDetailSections,
|
||||
MemoryFlowEvent,
|
||||
MemoryFlowPlannedWorkUnit,
|
||||
MemoryFlowReplayInput,
|
||||
} from './types.js';
|
||||
|
||||
interface ReportReplayOptions {
|
||||
provenanceRowCount?: number;
|
||||
}
|
||||
|
||||
function plannedWorkUnitFromLocal(
|
||||
workUnit: LocalIngestRunRecord['workUnits'][number],
|
||||
): MemoryFlowPlannedWorkUnit {
|
||||
return {
|
||||
unitKey: workUnit.unitKey,
|
||||
rawFiles: workUnit.rawFiles,
|
||||
peerFileCount: workUnit.peerFileIndex.length,
|
||||
dependencyCount: workUnit.dependencyPaths.length,
|
||||
};
|
||||
}
|
||||
|
||||
function plannedWorkUnitFromReport(
|
||||
workUnit: IngestReportSnapshot['body']['workUnits'][number],
|
||||
): MemoryFlowPlannedWorkUnit {
|
||||
return {
|
||||
unitKey: workUnit.unitKey,
|
||||
rawFiles: workUnit.rawFiles,
|
||||
peerFileCount: 0,
|
||||
dependencyCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function countActions(actions: MemoryAction[], target: MemoryAction['target']): number {
|
||||
return actions.filter((action) => action.target === target).length;
|
||||
}
|
||||
|
||||
function allReportActions(report: IngestReportSnapshot): MemoryAction[] {
|
||||
return report.body.workUnits.flatMap((workUnit) => workUnit.actions);
|
||||
}
|
||||
|
||||
function rawFileCount(report: IngestReportSnapshot): number {
|
||||
return new Set(report.body.workUnits.flatMap((workUnit) => workUnit.rawFiles)).size;
|
||||
}
|
||||
|
||||
function emptyMemoryFlowDetails(): MemoryFlowDetailSections {
|
||||
return { actions: [], provenance: [], transcripts: [] };
|
||||
}
|
||||
|
||||
function fullModeMetadata(input: {
|
||||
origin: 'captured' | 'synthetic-report';
|
||||
timing: 'captured' | 'synthetic';
|
||||
capturedAt: string | null;
|
||||
sourceReportId: string | null;
|
||||
sourceReportPath: string | null;
|
||||
fallbackReason: string | null;
|
||||
}): MemoryFlowReplayInput['metadata'] {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: input.origin,
|
||||
timing: input.timing,
|
||||
capturedAt: input.capturedAt,
|
||||
sourceReportId: input.sourceReportId,
|
||||
sourceReportPath: input.sourceReportPath,
|
||||
fallbackReason: input.fallbackReason,
|
||||
};
|
||||
}
|
||||
|
||||
function reportStatus(report: IngestReportSnapshot): MemoryFlowReplayInput['status'] {
|
||||
return report.body.failedWorkUnits.length > 0 ? 'error' : 'done';
|
||||
}
|
||||
|
||||
function reportCreatedEvent(report: IngestReportSnapshot): MemoryFlowEvent {
|
||||
return { type: 'report_created', runId: report.runId, reportPath: report.id };
|
||||
}
|
||||
|
||||
function capturedReportReplay(report: IngestReportSnapshot): MemoryFlowReplayInput | null {
|
||||
if (!report.body.memoryFlow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasReportCreated = report.body.memoryFlow.events.some((event) => event.type === 'report_created');
|
||||
return {
|
||||
...report.body.memoryFlow,
|
||||
metadata: fullModeMetadata({
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: report.body.memoryFlow.metadata?.capturedAt ?? report.createdAt,
|
||||
sourceReportId: report.id,
|
||||
sourceReportPath: report.id,
|
||||
fallbackReason: null,
|
||||
}),
|
||||
runId: report.runId,
|
||||
connectionId: report.connectionId,
|
||||
adapter: report.sourceKey,
|
||||
status: reportStatus(report),
|
||||
syncId: report.body.syncId,
|
||||
reportId: report.id,
|
||||
reportPath: report.id,
|
||||
errors: report.body.failedWorkUnits,
|
||||
events: hasReportCreated ? report.body.memoryFlow.events : [...report.body.memoryFlow.events, reportCreatedEvent(report)],
|
||||
};
|
||||
}
|
||||
|
||||
function actionDetailsFromReport(report: IngestReportSnapshot): MemoryFlowActionDetail[] {
|
||||
return report.body.workUnits.flatMap((workUnit) =>
|
||||
workUnit.actions.map((action) => ({
|
||||
unitKey: workUnit.unitKey,
|
||||
target: action.target,
|
||||
action: action.type,
|
||||
key: action.key,
|
||||
summary: action.detail,
|
||||
rawFiles: [...workUnit.rawFiles],
|
||||
status: workUnit.status,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function detailSectionsFromReport(report: IngestReportSnapshot): MemoryFlowDetailSections {
|
||||
return {
|
||||
actions: actionDetailsFromReport(report),
|
||||
provenance: report.body.provenanceRows.map((row) => ({ ...row })),
|
||||
transcripts: report.body.toolTranscripts.map((summary) => ({
|
||||
...summary,
|
||||
toolNames: [...summary.toolNames],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function localIngestRunToMemoryFlowReplay(record: LocalIngestRunRecord): MemoryFlowReplayInput {
|
||||
const events: MemoryFlowEvent[] = [
|
||||
{ type: 'source_acquired', adapter: record.adapter, trigger: 'manual_resync', fileCount: record.rawFileCount },
|
||||
{ type: 'scope_detected', fingerprint: null },
|
||||
{ type: 'raw_snapshot_written', syncId: record.syncId, rawFileCount: record.rawFileCount },
|
||||
{ type: 'diff_computed', ...record.diffSummary },
|
||||
{
|
||||
type: 'chunks_planned',
|
||||
chunkCount: record.workUnitCount,
|
||||
workUnitCount: record.workUnitCount,
|
||||
evictionCount: record.evictionDeletedRawPaths.length,
|
||||
},
|
||||
{ type: 'report_created', runId: record.runId },
|
||||
];
|
||||
|
||||
return {
|
||||
runId: record.runId,
|
||||
connectionId: record.connectionId,
|
||||
adapter: record.adapter,
|
||||
status: record.status,
|
||||
sourceDir: record.sourceDir,
|
||||
syncId: record.syncId,
|
||||
errors: record.errors,
|
||||
events,
|
||||
plannedWorkUnits: record.workUnits.map(plannedWorkUnitFromLocal),
|
||||
details: emptyMemoryFlowDetails(),
|
||||
};
|
||||
}
|
||||
|
||||
export function ingestReportToMemoryFlowReplay(
|
||||
report: IngestReportSnapshot,
|
||||
options: ReportReplayOptions = {},
|
||||
): MemoryFlowReplayInput {
|
||||
const captured = capturedReportReplay(report);
|
||||
if (captured) {
|
||||
return captured;
|
||||
}
|
||||
|
||||
const actions = allReportActions(report);
|
||||
const workUnitEvents: MemoryFlowEvent[] = report.body.workUnits.flatMap((workUnit) => [
|
||||
{ type: 'work_unit_started', unitKey: workUnit.unitKey, skills: [], stepBudget: 0 } satisfies MemoryFlowEvent,
|
||||
...workUnit.actions.map(
|
||||
(action): MemoryFlowEvent => ({
|
||||
type: 'candidate_action',
|
||||
unitKey: workUnit.unitKey,
|
||||
target: action.target,
|
||||
action: action.type,
|
||||
key: action.key,
|
||||
}),
|
||||
),
|
||||
{
|
||||
type: 'work_unit_finished',
|
||||
unitKey: workUnit.unitKey,
|
||||
status: workUnit.status,
|
||||
...(workUnit.reason ? { reason: workUnit.reason } : {}),
|
||||
} satisfies MemoryFlowEvent,
|
||||
]);
|
||||
|
||||
const events: MemoryFlowEvent[] = [
|
||||
{
|
||||
type: 'source_acquired',
|
||||
adapter: report.sourceKey,
|
||||
trigger: 'manual_resync',
|
||||
fileCount: rawFileCount(report),
|
||||
},
|
||||
{ type: 'scope_detected', fingerprint: null },
|
||||
{ type: 'raw_snapshot_written', syncId: report.body.syncId, rawFileCount: rawFileCount(report) },
|
||||
{ type: 'diff_computed', ...report.body.diffSummary },
|
||||
{
|
||||
type: 'chunks_planned',
|
||||
chunkCount: report.body.workUnits.length,
|
||||
workUnitCount: report.body.workUnits.length,
|
||||
evictionCount: report.body.evictionInputs.length,
|
||||
},
|
||||
...workUnitEvents,
|
||||
{
|
||||
type: 'reconciliation_finished',
|
||||
conflictCount: report.body.conflictsResolved.length,
|
||||
fallbackCount: report.body.unmappedFallbacks.length,
|
||||
},
|
||||
{
|
||||
type: 'saved',
|
||||
commitSha: report.body.commitSha,
|
||||
wikiCount: countActions(actions, 'wiki'),
|
||||
slCount: countActions(actions, 'sl'),
|
||||
},
|
||||
{ type: 'provenance_recorded', rowCount: options.provenanceRowCount ?? actions.length },
|
||||
{ type: 'report_created', runId: report.runId, reportPath: report.id },
|
||||
];
|
||||
|
||||
return {
|
||||
metadata: fullModeMetadata({
|
||||
origin: 'synthetic-report',
|
||||
timing: 'synthetic',
|
||||
capturedAt: report.createdAt,
|
||||
sourceReportId: report.id,
|
||||
sourceReportPath: report.id,
|
||||
fallbackReason: 'report did not include captured memory-flow events',
|
||||
}),
|
||||
runId: report.runId,
|
||||
connectionId: report.connectionId,
|
||||
adapter: report.sourceKey,
|
||||
status: reportStatus(report),
|
||||
sourceDir: null,
|
||||
syncId: report.body.syncId,
|
||||
reportId: report.id,
|
||||
reportPath: report.id,
|
||||
errors: report.body.failedWorkUnits,
|
||||
events,
|
||||
plannedWorkUnits: report.body.workUnits.map(plannedWorkUnitFromReport),
|
||||
details: detailSectionsFromReport(report),
|
||||
};
|
||||
}
|
||||
17
packages/context/src/ingest/memory-flow/index.ts
Normal file
17
packages/context/src/ingest/memory-flow/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export {
|
||||
memoryFlowReplayInputSchema,
|
||||
memoryFlowStreamEventSchema,
|
||||
parseMemoryFlowReplayInput,
|
||||
} from './schema.js';
|
||||
export type { MemoryFlowStreamEvent } from './schema.js';
|
||||
export { buildMemoryFlowViewModel } from './view-model.js';
|
||||
export { renderMemoryFlowReplay } from './render.js';
|
||||
export { formatMemoryFlowFinalSummary } from './summary.js';
|
||||
export type {
|
||||
MemoryFlowDetailSections,
|
||||
MemoryFlowEvent,
|
||||
MemoryFlowPlannedWorkUnit,
|
||||
MemoryFlowReplayInput,
|
||||
MemoryFlowRunStatus,
|
||||
MemoryFlowViewModel,
|
||||
} from './types.js';
|
||||
326
packages/context/src/ingest/memory-flow/interaction.test.ts
Normal file
326
packages/context/src/ingest/memory-flow/interaction.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createInitialMemoryFlowInteractionState,
|
||||
findMemoryFlowSearchMatches,
|
||||
reduceMemoryFlowInteractionState,
|
||||
selectMemoryFlowChip,
|
||||
selectMemoryFlowColumn,
|
||||
selectedMemoryFlowColumn,
|
||||
selectedMemoryFlowDetails,
|
||||
visibleMemoryFlowChips,
|
||||
} from './interaction.js';
|
||||
import type { MemoryFlowInteractionState, MemoryFlowViewModel } from './types.js';
|
||||
|
||||
function view(): MemoryFlowViewModel {
|
||||
return {
|
||||
title: 'KLO memory flow warehouse/metricflow running',
|
||||
subtitle: 'Run run-1 Sync sync-1',
|
||||
status: 'running',
|
||||
activeLine: 'active: WorkUnit orders step 2/4',
|
||||
selectedTitle: 'WORKUNITS',
|
||||
selectedDetails: ['orders: 1 raw, 0 peers, 1 deps'],
|
||||
completionLine: null,
|
||||
trustIssues: [
|
||||
{
|
||||
id: 'flagged-fallbacks',
|
||||
severity: 'warning',
|
||||
title: 'Flagged fallbacks',
|
||||
detail: '1 fallback needs review',
|
||||
columnId: 'gates',
|
||||
},
|
||||
{
|
||||
id: 'work-unit-failed:customers',
|
||||
severity: 'failed',
|
||||
title: 'WorkUnit failed',
|
||||
detail: 'customers failed: semantic-layer validation failed',
|
||||
columnId: 'workUnits',
|
||||
targetLabel: 'customers',
|
||||
},
|
||||
],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/orders.md',
|
||||
summary: 'order facts',
|
||||
rawFiles: ['orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
provenance: [
|
||||
{
|
||||
rawPath: 'orders.yml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'customers',
|
||||
path: '/tmp/transcripts/customers.jsonl',
|
||||
toolCallCount: 2,
|
||||
errorCount: 1,
|
||||
toolNames: ['read_raw_span', 'sl_write_source'],
|
||||
},
|
||||
],
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'source',
|
||||
title: 'SOURCE',
|
||||
status: 'complete',
|
||||
headline: '2 raw files',
|
||||
counters: ['sync sync-1', 'scope none'],
|
||||
chips: [{ label: 'metricflow', status: 'complete' }],
|
||||
details: ['Trigger: manual_resync', 'Adapter: metricflow'],
|
||||
},
|
||||
{
|
||||
id: 'chunks',
|
||||
title: 'CHUNKS',
|
||||
status: 'complete',
|
||||
headline: '2 chunks',
|
||||
counters: ['+1 ~1 -0 =0', '0 deletions'],
|
||||
chips: [{ label: 'orders', status: 'complete' }],
|
||||
details: ['Work units planned: 2', 'Eviction candidates: 0'],
|
||||
},
|
||||
{
|
||||
id: 'workUnits',
|
||||
title: 'WORKUNITS',
|
||||
status: 'active',
|
||||
headline: '2 WUs',
|
||||
counters: ['1 done', '1 failed', '1 active'],
|
||||
chips: [
|
||||
{ label: 'orders', status: 'complete', detail: '1 raw span' },
|
||||
{ label: 'customers', status: 'failed', detail: 'semantic-layer validation failed' },
|
||||
],
|
||||
details: ['orders: 1 raw, 0 peers, 1 deps', 'customers: 1 raw, 0 peers, 0 deps'],
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
title: 'ACTIONS',
|
||||
status: 'complete',
|
||||
headline: '2 candidates',
|
||||
counters: ['1 wiki', '1 SL'],
|
||||
chips: [{ label: 'knowledge/orders.md', status: 'complete' }],
|
||||
details: ['wiki created: knowledge/orders.md', 'sl updated: warehouse.orders'],
|
||||
},
|
||||
{
|
||||
id: 'gates',
|
||||
title: 'GATES',
|
||||
status: 'warning',
|
||||
headline: '0 conflict, 1 fallback',
|
||||
counters: ['1 failed', '1 flagged'],
|
||||
chips: [{ label: 'customers', status: 'failed' }],
|
||||
details: ['Failed work units: 1', 'Flagged fallbacks: 1', 'customers: semantic-layer validation failed'],
|
||||
},
|
||||
{
|
||||
id: 'saved',
|
||||
title: 'SAVED',
|
||||
status: 'complete',
|
||||
headline: '2 memories',
|
||||
counters: ['1 wiki', '1 SL', '2 provenance'],
|
||||
chips: [{ label: 'abc12345', status: 'complete' }],
|
||||
details: ['Commit: abc12345', 'Run: run-1', 'Report: report-1', 'Provenance rows: 2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('memory-flow interaction reducer', () => {
|
||||
it('selects the active work-unit column by default', () => {
|
||||
const state = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
expect(state).toEqual({
|
||||
selectedColumnId: 'workUnits',
|
||||
selectedChipIndex: 0,
|
||||
expanded: false,
|
||||
pane: 'overview',
|
||||
filter: 'all',
|
||||
search: { editing: false, query: '', matchIndex: 0 },
|
||||
shouldQuit: false,
|
||||
});
|
||||
expect(selectedMemoryFlowColumn(view(), state).title).toBe('WORKUNITS');
|
||||
});
|
||||
|
||||
it('moves between columns and clamps chip selection', () => {
|
||||
let state = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'down', view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'down', view());
|
||||
expect(state.selectedChipIndex).toBe(1);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'right', view());
|
||||
expect(state.selectedColumnId).toBe('actions');
|
||||
expect(state.selectedChipIndex).toBe(0);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'left', view());
|
||||
expect(state.selectedColumnId).toBe('workUnits');
|
||||
expect(state.selectedChipIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('selects a column directly for mouse-driven renderers', () => {
|
||||
const initial = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
const selected = selectMemoryFlowColumn(view(), initial, 'actions');
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
selectedColumnId: 'actions',
|
||||
selectedChipIndex: 0,
|
||||
expanded: true,
|
||||
shouldQuit: false,
|
||||
});
|
||||
expect(selectedMemoryFlowColumn(view(), selected).title).toBe('ACTIONS');
|
||||
expect(selectedMemoryFlowDetails(view(), selected)).toContain('wiki created: knowledge/orders.md');
|
||||
});
|
||||
|
||||
it('selects and clamps a chip directly for mouse-driven renderers', () => {
|
||||
const initial = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
const selected = selectMemoryFlowChip(view(), initial, 'workUnits', 99);
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
selectedColumnId: 'workUnits',
|
||||
selectedChipIndex: 1,
|
||||
expanded: true,
|
||||
shouldQuit: false,
|
||||
});
|
||||
expect(selectedMemoryFlowDetails(view(), selected)).toContain(
|
||||
'Selected chip: customers (semantic-layer validation failed)',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores direct selection of an unknown column', () => {
|
||||
const initial = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
const selected = selectMemoryFlowColumn(view(), initial, 'missing' as never);
|
||||
|
||||
expect(selected).toEqual({ ...initial, shouldQuit: false });
|
||||
});
|
||||
|
||||
it('toggles expansion, attention filtering, all panes, and quit', () => {
|
||||
let state: MemoryFlowInteractionState = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'enter', view());
|
||||
expect(state.expanded).toBe(true);
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain('orders: 1 raw, 0 peers, 1 deps');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'filter', view());
|
||||
expect(state.filter).toBe('failed_or_flagged');
|
||||
expect(visibleMemoryFlowChips(selectedMemoryFlowColumn(view(), state), state)).toEqual([
|
||||
{ label: 'customers', status: 'failed', detail: 'semantic-layer validation failed' },
|
||||
]);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('trust');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('details');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('log');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain('WORKUNITS active: 2 WUs');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('provenance');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain(
|
||||
'orders.yml -> wiki:knowledge/orders.md (wiki_written)',
|
||||
);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('transcript');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain(
|
||||
'customers: 2 tool calls, 1 errors, tools read_raw_span, sl_write_source',
|
||||
);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('overview');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'provenance', view());
|
||||
expect(state.pane).toBe('provenance');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain(
|
||||
'orders.yml -> wiki:knowledge/orders.md (wiki_written)',
|
||||
);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'transcript', view());
|
||||
expect(state.pane).toBe('transcript');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toContain(
|
||||
'customers: 2 tool calls, 1 errors, tools read_raw_span, sl_write_source',
|
||||
);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'quit', view());
|
||||
expect(state.shouldQuit).toBe(true);
|
||||
});
|
||||
|
||||
it('shows trust issue details and filters chips using issue targets', () => {
|
||||
let state: MemoryFlowInteractionState = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'tab', view());
|
||||
expect(state.pane).toBe('trust');
|
||||
expect(selectedMemoryFlowDetails(view(), state)).toEqual([
|
||||
'FAILED WorkUnit failed: customers failed: semantic-layer validation failed',
|
||||
'WARNING Flagged fallbacks: 1 fallback needs review',
|
||||
]);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'filter', view());
|
||||
expect(visibleMemoryFlowChips(selectedMemoryFlowColumn(view(), state), state, view())).toEqual([
|
||||
{ label: 'customers', status: 'failed', detail: 'semantic-layer validation failed' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('searches across columns, trust issues, actions, provenance, and transcripts', () => {
|
||||
const matches = findMemoryFlowSearchMatches(view(), 'customers');
|
||||
|
||||
expect(matches.map((match) => match.label)).toEqual([
|
||||
'WORKUNITS > customers',
|
||||
'GATES',
|
||||
'Trust > WorkUnit failed',
|
||||
'Transcript > customers',
|
||||
]);
|
||||
|
||||
let state = createInitialMemoryFlowInteractionState(view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-start', view());
|
||||
state = reduceMemoryFlowInteractionState(state, { type: 'search-input', value: 'customers' }, view());
|
||||
|
||||
expect(state.search).toEqual({
|
||||
editing: true,
|
||||
query: 'customers',
|
||||
matchIndex: 0,
|
||||
});
|
||||
expect(state.selectedColumnId).toBe('workUnits');
|
||||
expect(state.selectedChipIndex).toBe(1);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-submit', view());
|
||||
expect(state.search.editing).toBe(false);
|
||||
});
|
||||
|
||||
it('cycles search matches forward and backward with wraparound', () => {
|
||||
let state = createInitialMemoryFlowInteractionState(view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-start', view());
|
||||
state = reduceMemoryFlowInteractionState(state, { type: 'search-input', value: 'customers' }, view());
|
||||
|
||||
expect(state.search).toEqual({ editing: true, query: 'customers', matchIndex: 0 });
|
||||
expect(state.selectedColumnId).toBe('workUnits');
|
||||
expect(state.selectedChipIndex).toBe(1);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-next', view());
|
||||
expect(state.search).toEqual({ editing: true, query: 'customers', matchIndex: 1 });
|
||||
expect(state.selectedColumnId).toBe('gates');
|
||||
expect(state.selectedChipIndex).toBe(0);
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-next', view());
|
||||
expect(state.search).toEqual({ editing: true, query: 'customers', matchIndex: 2 });
|
||||
expect(state.selectedColumnId).toBe('workUnits');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-previous', view());
|
||||
expect(state.search).toEqual({ editing: true, query: 'customers', matchIndex: 1 });
|
||||
expect(state.selectedColumnId).toBe('gates');
|
||||
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-previous', view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'search-previous', view());
|
||||
expect(state.search).toEqual({ editing: true, query: 'customers', matchIndex: 3 });
|
||||
expect(state.selectedColumnId).toBe('workUnits');
|
||||
});
|
||||
});
|
||||
450
packages/context/src/ingest/memory-flow/interaction.ts
Normal file
450
packages/context/src/ingest/memory-flow/interaction.ts
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
import type {
|
||||
MemoryFlowChip,
|
||||
MemoryFlowColumnView,
|
||||
MemoryFlowFilterMode,
|
||||
MemoryFlowInteractionCommand,
|
||||
MemoryFlowInteractionState,
|
||||
MemoryFlowPaneId,
|
||||
MemoryFlowSearchMatch,
|
||||
MemoryFlowViewModel,
|
||||
} from './types.js';
|
||||
|
||||
const CYCLING_PANES: MemoryFlowPaneId[] = ['overview', 'trust', 'details', 'log', 'provenance', 'transcript'];
|
||||
|
||||
function attentionStatus(status: MemoryFlowChip['status']): boolean {
|
||||
return status === 'failed' || status === 'warning';
|
||||
}
|
||||
|
||||
function trustIssueTargets(view: MemoryFlowViewModel, column: MemoryFlowColumnView): Set<string> {
|
||||
return new Set(
|
||||
view.trustIssues
|
||||
.filter((issue) => issue.columnId === column.id && issue.targetLabel)
|
||||
.map((issue) => issue.targetLabel as string),
|
||||
);
|
||||
}
|
||||
|
||||
function columnIndex(view: MemoryFlowViewModel, columnId: MemoryFlowInteractionState['selectedColumnId']): number {
|
||||
const index = view.columns.findIndex((column) => column.id === columnId);
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
function clampChipIndex(column: MemoryFlowColumnView, state: MemoryFlowInteractionState, view?: MemoryFlowViewModel): number {
|
||||
const chips = visibleMemoryFlowChips(column, state, view);
|
||||
if (chips.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(state.selectedChipIndex, chips.length - 1));
|
||||
}
|
||||
|
||||
function withColumn(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
direction: -1 | 1,
|
||||
): MemoryFlowInteractionState {
|
||||
const nextIndex = Math.max(0, Math.min(columnIndex(view, state.selectedColumnId) + direction, view.columns.length - 1));
|
||||
const selectedColumnId = view.columns[nextIndex]?.id ?? state.selectedColumnId;
|
||||
const nextState = { ...state, selectedColumnId, selectedChipIndex: 0, expanded: false };
|
||||
return { ...nextState, selectedChipIndex: clampChipIndex(selectedMemoryFlowColumn(view, nextState), nextState, view) };
|
||||
}
|
||||
|
||||
function nextPane(current: MemoryFlowPaneId): MemoryFlowPaneId {
|
||||
const currentIndex = CYCLING_PANES.indexOf(current);
|
||||
if (currentIndex === -1) {
|
||||
return 'overview';
|
||||
}
|
||||
return CYCLING_PANES[(currentIndex + 1) % CYCLING_PANES.length] ?? 'overview';
|
||||
}
|
||||
|
||||
function toggleFilter(filter: MemoryFlowFilterMode): MemoryFlowFilterMode {
|
||||
return filter === 'all' ? 'failed_or_flagged' : 'all';
|
||||
}
|
||||
|
||||
export function visibleMemoryFlowChips(
|
||||
column: MemoryFlowColumnView,
|
||||
state: Pick<MemoryFlowInteractionState, 'filter'>,
|
||||
view?: MemoryFlowViewModel,
|
||||
): MemoryFlowChip[] {
|
||||
if (state.filter === 'all') {
|
||||
return column.chips;
|
||||
}
|
||||
|
||||
const issueTargets = view ? trustIssueTargets(view, column) : new Set<string>();
|
||||
return column.chips.filter((chip) => attentionStatus(chip.status) || issueTargets.has(chip.label));
|
||||
}
|
||||
|
||||
function includesQuery(value: string, query: string): boolean {
|
||||
return value.toLocaleLowerCase().includes(query.toLocaleLowerCase());
|
||||
}
|
||||
|
||||
function pushMatch(
|
||||
matches: MemoryFlowSearchMatch[],
|
||||
query: string,
|
||||
match: MemoryFlowSearchMatch,
|
||||
values: string[],
|
||||
): void {
|
||||
if (values.some((value) => includesQuery(value, query))) {
|
||||
matches.push(match);
|
||||
}
|
||||
}
|
||||
|
||||
export function findMemoryFlowSearchMatches(view: MemoryFlowViewModel, query: string): MemoryFlowSearchMatch[] {
|
||||
const normalized = query.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches: MemoryFlowSearchMatch[] = [];
|
||||
for (const column of view.columns) {
|
||||
const chipMatches = column.chips
|
||||
.map((chip, chipIndex) => ({ chip, chipIndex }))
|
||||
.filter(({ chip }) => includesQuery(chip.label, normalized) || includesQuery(chip.detail ?? '', normalized));
|
||||
|
||||
for (const { chip, chipIndex } of chipMatches) {
|
||||
if (column.id === 'workUnits' || column.id === 'actions') {
|
||||
matches.push({
|
||||
columnId: column.id,
|
||||
chipIndex,
|
||||
label: `${column.title} > ${chip.label}`,
|
||||
detail: chip.detail ?? column.headline,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (chipMatches.length === 0 || column.id !== 'workUnits') {
|
||||
pushMatch(matches, normalized, { columnId: column.id, label: column.title, detail: column.headline }, [
|
||||
column.title,
|
||||
column.headline,
|
||||
...column.counters,
|
||||
...column.details,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const issue of view.trustIssues) {
|
||||
pushMatch(
|
||||
matches,
|
||||
normalized,
|
||||
{ columnId: issue.columnId, label: `Trust > ${issue.title}`, detail: issue.detail },
|
||||
[issue.title, issue.detail, issue.targetLabel ?? ''],
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of view.details.provenance) {
|
||||
pushMatch(
|
||||
matches,
|
||||
normalized,
|
||||
{
|
||||
columnId: 'saved',
|
||||
label: `Provenance > ${row.rawPath}`,
|
||||
detail: `${row.rawPath} ${row.artifactKind ?? 'none'} ${row.artifactKey ?? 'none'} ${row.actionType}`,
|
||||
},
|
||||
[row.rawPath, row.artifactKind ?? '', row.artifactKey ?? '', row.actionType],
|
||||
);
|
||||
}
|
||||
|
||||
for (const transcript of view.details.transcripts) {
|
||||
pushMatch(
|
||||
matches,
|
||||
normalized,
|
||||
{
|
||||
columnId: 'workUnits',
|
||||
label: `Transcript > ${transcript.unitKey}`,
|
||||
detail: `${transcript.path} ${transcript.toolNames.join(' ')}`,
|
||||
},
|
||||
[transcript.unitKey, transcript.path, ...transcript.toolNames],
|
||||
);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function selectSearchMatch(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
query: string,
|
||||
matchIndex: number,
|
||||
): MemoryFlowInteractionState {
|
||||
const matches = findMemoryFlowSearchMatches(view, query);
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
...state,
|
||||
search: { editing: state.search.editing, query, matchIndex: 0 },
|
||||
shouldQuit: false,
|
||||
};
|
||||
}
|
||||
|
||||
const index = Math.max(0, Math.min(matchIndex, matches.length - 1));
|
||||
const match = matches[index]!;
|
||||
const nextState = {
|
||||
...state,
|
||||
selectedColumnId: match.columnId,
|
||||
selectedChipIndex: match.chipIndex ?? 0,
|
||||
expanded: true,
|
||||
search: { editing: state.search.editing, query, matchIndex: index },
|
||||
shouldQuit: false,
|
||||
};
|
||||
return {
|
||||
...nextState,
|
||||
selectedChipIndex: clampChipIndex(selectedMemoryFlowColumn(view, nextState), nextState, view),
|
||||
};
|
||||
}
|
||||
|
||||
function moveSearchMatch(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
direction: -1 | 1,
|
||||
): MemoryFlowInteractionState {
|
||||
const query = state.search.query.trim();
|
||||
if (!query) {
|
||||
return { ...state, search: { ...state.search, matchIndex: 0 }, shouldQuit: false };
|
||||
}
|
||||
|
||||
const matches = findMemoryFlowSearchMatches(view, query);
|
||||
if (matches.length === 0) {
|
||||
return { ...state, search: { ...state.search, matchIndex: 0 }, shouldQuit: false };
|
||||
}
|
||||
|
||||
const nextIndex = (state.search.matchIndex + direction + matches.length) % matches.length;
|
||||
return selectSearchMatch(view, state, state.search.query, nextIndex);
|
||||
}
|
||||
|
||||
export function selectedMemoryFlowColumn(
|
||||
view: MemoryFlowViewModel,
|
||||
state: Pick<MemoryFlowInteractionState, 'selectedColumnId'>,
|
||||
): MemoryFlowColumnView {
|
||||
return view.columns.find((column) => column.id === state.selectedColumnId) ?? view.columns[0]!;
|
||||
}
|
||||
|
||||
export function createInitialMemoryFlowInteractionState(view: MemoryFlowViewModel): MemoryFlowInteractionState {
|
||||
const column =
|
||||
view.columns.find((candidate) => candidate.status === 'active') ??
|
||||
view.columns.find((candidate) => candidate.status === 'failed' || candidate.status === 'warning') ??
|
||||
view.columns.find((candidate) => candidate.details.length > 0) ??
|
||||
view.columns[0]!;
|
||||
|
||||
return {
|
||||
selectedColumnId: column.id,
|
||||
selectedChipIndex: 0,
|
||||
expanded: false,
|
||||
pane: 'overview',
|
||||
filter: 'all',
|
||||
search: { editing: false, query: '', matchIndex: 0 },
|
||||
shouldQuit: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectMemoryFlowColumn(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
columnId: MemoryFlowInteractionState['selectedColumnId'],
|
||||
): MemoryFlowInteractionState {
|
||||
const column = view.columns.find((candidate) => candidate.id === columnId);
|
||||
if (!column) {
|
||||
return { ...state, shouldQuit: false };
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
selectedColumnId: column.id,
|
||||
selectedChipIndex: 0,
|
||||
expanded: true,
|
||||
shouldQuit: false,
|
||||
};
|
||||
return { ...nextState, selectedChipIndex: clampChipIndex(column, nextState, view) };
|
||||
}
|
||||
|
||||
export function selectMemoryFlowChip(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
columnId: MemoryFlowInteractionState['selectedColumnId'],
|
||||
chipIndex: number,
|
||||
): MemoryFlowInteractionState {
|
||||
const column = view.columns.find((candidate) => candidate.id === columnId);
|
||||
if (!column) {
|
||||
return { ...state, shouldQuit: false };
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
...state,
|
||||
selectedColumnId: column.id,
|
||||
selectedChipIndex: Math.max(0, chipIndex),
|
||||
expanded: true,
|
||||
shouldQuit: false,
|
||||
};
|
||||
return { ...nextState, selectedChipIndex: clampChipIndex(column, nextState, view) };
|
||||
}
|
||||
|
||||
export function reduceMemoryFlowInteractionState(
|
||||
state: MemoryFlowInteractionState,
|
||||
command: MemoryFlowInteractionCommand,
|
||||
view: MemoryFlowViewModel,
|
||||
): MemoryFlowInteractionState {
|
||||
if (command === 'search-start') {
|
||||
return { ...state, pane: 'details', search: { ...state.search, editing: true }, shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'search-submit') {
|
||||
return { ...state, search: { ...state.search, editing: false }, shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'search-clear') {
|
||||
return { ...state, search: { editing: false, query: '', matchIndex: 0 }, shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'search-backspace') {
|
||||
return selectSearchMatch(view, state, state.search.query.slice(0, -1), 0);
|
||||
}
|
||||
|
||||
if (command === 'search-next') {
|
||||
return moveSearchMatch(view, state, 1);
|
||||
}
|
||||
|
||||
if (command === 'search-previous') {
|
||||
return moveSearchMatch(view, state, -1);
|
||||
}
|
||||
|
||||
if (typeof command === 'object' && command.type === 'search-input') {
|
||||
return selectSearchMatch(view, state, `${state.search.query}${command.value}`, 0);
|
||||
}
|
||||
|
||||
if (command === 'quit') {
|
||||
return { ...state, shouldQuit: true };
|
||||
}
|
||||
|
||||
if (command === 'left') {
|
||||
return withColumn(view, { ...state, shouldQuit: false }, -1);
|
||||
}
|
||||
|
||||
if (command === 'right') {
|
||||
return withColumn(view, { ...state, shouldQuit: false }, 1);
|
||||
}
|
||||
|
||||
if (command === 'up' || command === 'down') {
|
||||
const column = selectedMemoryFlowColumn(view, state);
|
||||
const visibleChips = visibleMemoryFlowChips(column, state, view);
|
||||
const delta = command === 'up' ? -1 : 1;
|
||||
return {
|
||||
...state,
|
||||
selectedChipIndex:
|
||||
visibleChips.length === 0
|
||||
? 0
|
||||
: Math.max(0, Math.min(state.selectedChipIndex + delta, visibleChips.length - 1)),
|
||||
shouldQuit: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === 'enter') {
|
||||
return { ...state, expanded: !state.expanded, shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'tab') {
|
||||
return { ...state, pane: nextPane(state.pane), shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'filter') {
|
||||
const nextState = { ...state, filter: toggleFilter(state.filter), selectedChipIndex: 0, shouldQuit: false };
|
||||
return {
|
||||
...nextState,
|
||||
selectedChipIndex: clampChipIndex(selectedMemoryFlowColumn(view, nextState), nextState, view),
|
||||
};
|
||||
}
|
||||
|
||||
if (command === 'provenance') {
|
||||
return { ...state, pane: 'provenance', expanded: true, shouldQuit: false };
|
||||
}
|
||||
|
||||
if (command === 'transcript') {
|
||||
return { ...state, pane: 'transcript', expanded: true, shouldQuit: false };
|
||||
}
|
||||
|
||||
return { ...state, shouldQuit: false };
|
||||
}
|
||||
|
||||
function trustIssueDetailLines(view: MemoryFlowViewModel): string[] {
|
||||
if (view.trustIssues.length === 0) {
|
||||
return ['No trust issues detected.'];
|
||||
}
|
||||
|
||||
return view.trustIssues
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
if (left.severity === right.severity) return 0;
|
||||
return left.severity === 'failed' ? -1 : 1;
|
||||
})
|
||||
.map((issue) => {
|
||||
const label = issue.severity === 'failed' ? 'FAILED' : 'WARNING';
|
||||
return `${label} ${issue.title}: ${issue.detail}`;
|
||||
});
|
||||
}
|
||||
|
||||
function provenanceDetailLines(view: MemoryFlowViewModel): string[] {
|
||||
if (view.details.provenance.length === 0) {
|
||||
const savedColumn = view.columns.find((candidate) => candidate.id === 'saved');
|
||||
return savedColumn?.details.length ? savedColumn.details : ['Provenance rows: 0'];
|
||||
}
|
||||
|
||||
return view.details.provenance.map((row) => {
|
||||
const artifact = row.artifactKind && row.artifactKey ? `${row.artifactKind}:${row.artifactKey}` : 'no saved artifact';
|
||||
return `${row.rawPath} -> ${artifact} (${row.actionType})`;
|
||||
});
|
||||
}
|
||||
|
||||
function transcriptDetailLines(view: MemoryFlowViewModel, selectedChip: MemoryFlowChip | undefined): string[] {
|
||||
const selectedUnit = selectedChip?.label;
|
||||
const transcripts =
|
||||
selectedUnit && view.details.transcripts.some((summary) => summary.unitKey === selectedUnit)
|
||||
? view.details.transcripts.filter((summary) => summary.unitKey === selectedUnit)
|
||||
: view.details.transcripts;
|
||||
|
||||
if (transcripts.length === 0) {
|
||||
const workUnitsColumn = view.columns.find((candidate) => candidate.id === 'workUnits');
|
||||
return workUnitsColumn?.details.length ? workUnitsColumn.details : ['No work-unit transcript summary available.'];
|
||||
}
|
||||
|
||||
return transcripts.map(
|
||||
(summary) =>
|
||||
`${summary.unitKey}: ${summary.toolCallCount} tool calls, ${summary.errorCount} errors, tools ${
|
||||
summary.toolNames.join(', ') || 'none'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectedMemoryFlowDetails(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string[] {
|
||||
const column = selectedMemoryFlowColumn(view, state);
|
||||
const chips = visibleMemoryFlowChips(column, state, view);
|
||||
const selectedChip = chips[state.selectedChipIndex];
|
||||
|
||||
if (state.pane === 'log') {
|
||||
return [
|
||||
view.activeLine,
|
||||
...view.columns.map((candidate) => `${candidate.title} ${candidate.status}: ${candidate.headline}`),
|
||||
...(view.completionLine ? [view.completionLine] : []),
|
||||
];
|
||||
}
|
||||
|
||||
if (state.pane === 'trust') {
|
||||
return trustIssueDetailLines(view);
|
||||
}
|
||||
|
||||
if (state.pane === 'provenance') {
|
||||
return provenanceDetailLines(view);
|
||||
}
|
||||
|
||||
if (state.pane === 'transcript') {
|
||||
return transcriptDetailLines(view, selectedChip);
|
||||
}
|
||||
|
||||
const baseDetails = column.details.length ? column.details : [`${column.title}: ${column.headline}`];
|
||||
if (state.pane === 'overview' && !state.expanded) {
|
||||
return [
|
||||
column.headline,
|
||||
...column.counters,
|
||||
...(selectedChip ? [`Selected chip: ${selectedChip.label}${selectedChip.detail ? ` (${selectedChip.detail})` : ''}`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...baseDetails,
|
||||
...(selectedChip ? [`Selected chip: ${selectedChip.label}${selectedChip.detail ? ` (${selectedChip.detail})` : ''}`] : []),
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { createInitialMemoryFlowInteractionState, reduceMemoryFlowInteractionState } from './interaction.js';
|
||||
import { renderMemoryFlowInteractive } from './interactive-render.js';
|
||||
import type { MemoryFlowViewModel } from './types.js';
|
||||
|
||||
function view(): MemoryFlowViewModel {
|
||||
return {
|
||||
title: 'KLO memory flow warehouse/metricflow done',
|
||||
subtitle: 'Run run-1 Sync sync-1',
|
||||
status: 'done',
|
||||
activeLine: 'active: complete',
|
||||
selectedTitle: 'WORKUNITS',
|
||||
selectedDetails: ['orders: 1 raw, 0 peers, 1 deps'],
|
||||
completionLine:
|
||||
'Saved 2 memories from 2 raw files: 1 wiki pages, 1 SL updates. Commit: abc12345 Run: run-1 Report: report-1',
|
||||
trustIssues: [
|
||||
{
|
||||
id: 'work-unit-failed:customers',
|
||||
severity: 'failed',
|
||||
title: 'WorkUnit failed',
|
||||
detail: 'customers failed: validation reset',
|
||||
columnId: 'workUnits',
|
||||
targetLabel: 'customers',
|
||||
},
|
||||
{
|
||||
id: 'flagged-fallbacks',
|
||||
severity: 'warning',
|
||||
title: 'Flagged fallbacks',
|
||||
detail: '1 fallback needs review',
|
||||
columnId: 'gates',
|
||||
},
|
||||
],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/orders.md',
|
||||
summary: 'order facts',
|
||||
rawFiles: ['orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
provenance: [
|
||||
{
|
||||
rawPath: 'orders.yml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'customers',
|
||||
path: '/tmp/transcripts/customers.jsonl',
|
||||
toolCallCount: 2,
|
||||
errorCount: 1,
|
||||
toolNames: ['read_raw_span', 'sl_write_source'],
|
||||
},
|
||||
],
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'source',
|
||||
title: 'SOURCE',
|
||||
status: 'complete',
|
||||
headline: '2 raw files',
|
||||
counters: ['sync sync-1', 'scope none'],
|
||||
chips: [{ label: 'metricflow', status: 'complete' }],
|
||||
details: ['Trigger: manual_resync', 'Adapter: metricflow'],
|
||||
},
|
||||
{
|
||||
id: 'chunks',
|
||||
title: 'CHUNKS',
|
||||
status: 'complete',
|
||||
headline: '2 chunks',
|
||||
counters: ['+1 ~1 -0 =0', '0 deletions'],
|
||||
chips: [{ label: 'orders', status: 'complete' }],
|
||||
details: ['Work units planned: 2', 'Eviction candidates: 0'],
|
||||
},
|
||||
{
|
||||
id: 'workUnits',
|
||||
title: 'WORKUNITS',
|
||||
status: 'warning',
|
||||
headline: '2 WUs',
|
||||
counters: ['1 done', '1 failed', '0 active'],
|
||||
chips: [
|
||||
{ label: 'orders', status: 'complete', detail: '1 raw span' },
|
||||
{ label: 'customers', status: 'failed', detail: 'validation reset' },
|
||||
],
|
||||
details: ['orders: 1 raw, 0 peers, 1 deps', 'customers: 1 raw, 0 peers, 0 deps'],
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
title: 'ACTIONS',
|
||||
status: 'complete',
|
||||
headline: '2 candidates',
|
||||
counters: ['1 wiki', '1 SL'],
|
||||
chips: [{ label: 'knowledge/orders.md', status: 'complete' }],
|
||||
details: ['wiki created: knowledge/orders.md', 'sl updated: warehouse.orders'],
|
||||
},
|
||||
{
|
||||
id: 'gates',
|
||||
title: 'GATES',
|
||||
status: 'warning',
|
||||
headline: '0 conflict, 1 fallback',
|
||||
counters: ['1 failed', '1 flagged'],
|
||||
chips: [{ label: 'customers', status: 'failed' }],
|
||||
details: ['Failed work units: 1', 'Flagged fallbacks: 1'],
|
||||
},
|
||||
{
|
||||
id: 'saved',
|
||||
title: 'SAVED',
|
||||
status: 'complete',
|
||||
headline: '2 memories',
|
||||
counters: ['1 wiki', '1 SL', '2 provenance'],
|
||||
chips: [{ label: 'abc12345', status: 'complete' }],
|
||||
details: ['Commit: abc12345', 'Run: run-1', 'Report: report-1', 'Provenance rows: 2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('renderMemoryFlowInteractive', () => {
|
||||
it('marks the selected column and selected chip in a wide layout', () => {
|
||||
const state = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
const output = renderMemoryFlowInteractive(view(), state, { terminalWidth: 140 });
|
||||
|
||||
expect(output).toContain('KLO memory flow warehouse/metricflow done');
|
||||
expect(output).toContain('OK SOURCE -> OK CHUNKS -> !! WORKUNITS -> OK ACTIONS -> !! GATES -> OK SAVED');
|
||||
expect(output).toContain('[WORKUNITS]');
|
||||
expect(output).toContain('> orders');
|
||||
expect(output).toContain('Selected: WORKUNITS > orders');
|
||||
expect(output).toContain('Pane: overview Filter: all');
|
||||
expect(output).toContain('- Selected chip: orders (1 raw span)');
|
||||
expect(output).toContain(
|
||||
'Saved 2 memories from 2 raw files: 1 wiki pages, 1 SL updates. Commit: abc12345 Run: run-1 Report: report-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders attention-filtered details in a narrow layout', () => {
|
||||
let state = createInitialMemoryFlowInteractionState(view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'filter', view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'enter', view());
|
||||
|
||||
const output = renderMemoryFlowInteractive(view(), state, { terminalWidth: 72 });
|
||||
|
||||
expect(output).toContain('OK SOURCE -> OK CHUNKS -> !! WORKUNITS -> OK ACTIONS -> !! GATES -> OK SAVED');
|
||||
expect(output).toContain('[WORKUNITS]');
|
||||
expect(output).toContain('Filter: failed_or_flagged');
|
||||
expect(output).toContain('> customers');
|
||||
expect(output).toContain('- customers: 1 raw, 0 peers, 0 deps');
|
||||
});
|
||||
|
||||
it('renders report-backed transcript detail pane rows', () => {
|
||||
let state = createInitialMemoryFlowInteractionState(view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'down', view());
|
||||
state = reduceMemoryFlowInteractionState(state, 'transcript', view());
|
||||
|
||||
const output = renderMemoryFlowInteractive(view(), state, { terminalWidth: 100 });
|
||||
|
||||
expect(output).toContain('Pane: transcript Filter: all');
|
||||
expect(output).toContain('- customers: 2 tool calls, 1 errors, tools read_raw_span, sl_write_source');
|
||||
});
|
||||
|
||||
it('keeps trust issues visible in the interactive renderer', () => {
|
||||
const state = createInitialMemoryFlowInteractionState(view());
|
||||
|
||||
const output = renderMemoryFlowInteractive(view(), state, { terminalWidth: 140 });
|
||||
|
||||
expect(output).toContain('Trust issues');
|
||||
expect(output).toContain('FAILED WorkUnit failed: customers failed: validation reset');
|
||||
expect(output).toContain('WARNING Flagged fallbacks: 1 fallback needs review');
|
||||
});
|
||||
});
|
||||
160
packages/context/src/ingest/memory-flow/interactive-render.ts
Normal file
160
packages/context/src/ingest/memory-flow/interactive-render.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import {
|
||||
findMemoryFlowSearchMatches,
|
||||
selectedMemoryFlowColumn,
|
||||
selectedMemoryFlowDetails,
|
||||
visibleMemoryFlowChips,
|
||||
} from './interaction.js';
|
||||
import type {
|
||||
MemoryFlowColumnView,
|
||||
MemoryFlowInteractionState,
|
||||
MemoryFlowRenderOptions,
|
||||
MemoryFlowViewModel,
|
||||
} from './types.js';
|
||||
import { renderMemoryFlowConnectorLine } from './visuals.js';
|
||||
|
||||
const WIDE_COLUMN_WIDTH = 18;
|
||||
|
||||
function cell(value: string | undefined, width = WIDE_COLUMN_WIDTH): string {
|
||||
const text = value ?? '';
|
||||
const normalized = text.length > width ? text.slice(0, width - 1) : text;
|
||||
return normalized.padEnd(width, ' ');
|
||||
}
|
||||
|
||||
function row(values: string[]): string {
|
||||
return values.map((value) => cell(value)).join(' ').trimEnd();
|
||||
}
|
||||
|
||||
function columnLabel(column: MemoryFlowColumnView, state: MemoryFlowInteractionState): string {
|
||||
return column.id === state.selectedColumnId ? `[${column.title}]` : column.title;
|
||||
}
|
||||
|
||||
function counterAt(column: MemoryFlowColumnView, index: number): string {
|
||||
return column.counters[index] ?? '';
|
||||
}
|
||||
|
||||
function chipLabel(view: MemoryFlowViewModel, column: MemoryFlowColumnView, state: MemoryFlowInteractionState): string {
|
||||
const chips = visibleMemoryFlowChips(column, state, view);
|
||||
if (chips.length === 0) {
|
||||
return '-';
|
||||
}
|
||||
const selectedIndex = column.id === state.selectedColumnId ? state.selectedChipIndex : -1;
|
||||
return chips
|
||||
.slice(0, 2)
|
||||
.map((chip, index) => `${index === selectedIndex ? '> ' : ''}${chip.label}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function selectedLine(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string {
|
||||
const column = selectedMemoryFlowColumn(view, state);
|
||||
const chip = visibleMemoryFlowChips(column, state, view)[state.selectedChipIndex];
|
||||
return `Selected: ${column.title}${chip ? ` > ${chip.label}` : ''}`;
|
||||
}
|
||||
|
||||
function trustIssueLines(view: MemoryFlowViewModel): string[] {
|
||||
if (view.trustIssues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Trust issues',
|
||||
...view.trustIssues.slice(0, 4).map((issue) => {
|
||||
const label = issue.severity === 'failed' ? 'FAILED' : 'WARNING';
|
||||
return `${label} ${issue.title}: ${issue.detail}`;
|
||||
}),
|
||||
...(view.trustIssues.length > 4 ? [`+${view.trustIssues.length - 4} more trust issues`] : []),
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
function searchLine(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string | null {
|
||||
if (!state.search.editing && state.search.query.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = findMemoryFlowSearchMatches(view, state.search.query);
|
||||
const active = state.search.editing ? 'editing' : 'locked';
|
||||
return `Search: ${state.search.query || '/'} (${matches.length} matches, ${active})`;
|
||||
}
|
||||
|
||||
function detailLines(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string[] {
|
||||
const currentSearchLine = searchLine(view, state);
|
||||
return [
|
||||
selectedLine(view, state),
|
||||
`Pane: ${state.pane} Filter: ${state.filter}`,
|
||||
...(currentSearchLine ? [currentSearchLine] : []),
|
||||
...selectedMemoryFlowDetails(view, state).map((detail) => `- ${detail}`),
|
||||
];
|
||||
}
|
||||
|
||||
function renderWide(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string {
|
||||
const lines = [
|
||||
view.title,
|
||||
view.activeLine,
|
||||
view.subtitle,
|
||||
renderMemoryFlowConnectorLine(view),
|
||||
...trustIssueLines(view),
|
||||
'',
|
||||
row(view.columns.map((column) => columnLabel(column, state))),
|
||||
row(view.columns.map((column) => column.headline)),
|
||||
row(view.columns.map((column) => counterAt(column, 0))),
|
||||
row(view.columns.map((column) => counterAt(column, 1))),
|
||||
row(view.columns.map((column) => chipLabel(view, column, state))),
|
||||
'',
|
||||
...detailLines(view, state),
|
||||
];
|
||||
|
||||
if (view.completionLine) {
|
||||
lines.push('', view.completionLine);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderNarrowColumn(
|
||||
view: MemoryFlowViewModel,
|
||||
column: MemoryFlowColumnView,
|
||||
state: MemoryFlowInteractionState,
|
||||
): string[] {
|
||||
return [
|
||||
columnLabel(column, state),
|
||||
` ${column.headline}`,
|
||||
...column.counters.slice(0, 3).map((counter) => ` ${counter}`),
|
||||
` ${chipLabel(view, column, state)}`,
|
||||
];
|
||||
}
|
||||
|
||||
function renderNarrow(view: MemoryFlowViewModel, state: MemoryFlowInteractionState): string {
|
||||
const lines = [
|
||||
view.title,
|
||||
view.activeLine,
|
||||
view.subtitle,
|
||||
renderMemoryFlowConnectorLine(view),
|
||||
...trustIssueLines(view),
|
||||
'',
|
||||
...view.columns.flatMap((column, index) => [
|
||||
...(index > 0 ? [''] : []),
|
||||
...renderNarrowColumn(view, column, state),
|
||||
]),
|
||||
'',
|
||||
...detailLines(view, state),
|
||||
];
|
||||
|
||||
if (view.completionLine) {
|
||||
lines.push('', view.completionLine);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderMemoryFlowInteractive(
|
||||
view: MemoryFlowViewModel,
|
||||
state: MemoryFlowInteractionState,
|
||||
options: MemoryFlowRenderOptions = {},
|
||||
): string {
|
||||
if ((options.terminalWidth ?? 120) < 100) {
|
||||
return renderNarrow(view, state);
|
||||
}
|
||||
return renderWide(view, state);
|
||||
}
|
||||
91
packages/context/src/ingest/memory-flow/live-buffer.test.ts
Normal file
91
packages/context/src/ingest/memory-flow/live-buffer.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createMemoryFlowLiveBuffer, sanitizeMemoryFlowError } from './live-buffer.js';
|
||||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
|
||||
function initialReplay(): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'live-run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
status: 'running',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'pending',
|
||||
errors: [],
|
||||
events: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe('createMemoryFlowLiveBuffer', () => {
|
||||
it('emits immutable replay snapshots on every live change', () => {
|
||||
const onChange = vi.fn();
|
||||
const buffer = createMemoryFlowLiveBuffer(initialReplay(), { onChange });
|
||||
|
||||
buffer.emit({ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 });
|
||||
buffer.update({
|
||||
syncId: 'sync-1',
|
||||
plannedWorkUnits: [
|
||||
{
|
||||
unitKey: 'fake-orders',
|
||||
rawFiles: ['orders.json'],
|
||||
peerFileCount: 0,
|
||||
dependencyCount: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
buffer.emit({ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 });
|
||||
buffer.finish('done');
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(4);
|
||||
expect(buffer.snapshot()).toMatchObject({
|
||||
runId: 'live-run-1',
|
||||
status: 'done',
|
||||
syncId: 'sync-1',
|
||||
plannedWorkUnits: [{ unitKey: 'fake-orders' }],
|
||||
});
|
||||
expect(buffer.snapshot().events.map((event) => event.type)).toEqual(['source_acquired', 'chunks_planned']);
|
||||
|
||||
const staleSnapshot = onChange.mock.calls[1][0] as MemoryFlowReplayInput;
|
||||
expect(staleSnapshot.details).toEqual({ actions: [], provenance: [], transcripts: [] });
|
||||
staleSnapshot.events.push({ type: 'report_created', runId: 'mutated' });
|
||||
expect(buffer.snapshot().events.map((event) => event.type)).toEqual(['source_acquired', 'chunks_planned']);
|
||||
});
|
||||
|
||||
it('stamps live events with emittedAt without mutating caller events', () => {
|
||||
const event = { type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 2 } as const;
|
||||
const buffer = createMemoryFlowLiveBuffer(initialReplay(), {
|
||||
now: () => new Date('2026-05-01T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
buffer.emit(event);
|
||||
|
||||
expect(event).not.toHaveProperty('emittedAt');
|
||||
expect(buffer.snapshot().events).toEqual([
|
||||
{
|
||||
type: 'source_acquired',
|
||||
adapter: 'fake',
|
||||
trigger: 'manual_resync',
|
||||
fileCount: 2,
|
||||
emittedAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks failed runs with sanitized error messages', () => {
|
||||
const onChange = vi.fn();
|
||||
const buffer = createMemoryFlowLiveBuffer(initialReplay(), { onChange });
|
||||
|
||||
buffer.finish('error', [
|
||||
sanitizeMemoryFlowError(
|
||||
new Error('Connection failed for postgres://user:password@localhost:5432/db?api_key=abc password=secret'), // pragma: allowlist secret
|
||||
),
|
||||
]);
|
||||
|
||||
expect(buffer.snapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
errors: ['Connection failed for postgres://[redacted] password=[redacted]'],
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
74
packages/context/src/ingest/memory-flow/live-buffer.ts
Normal file
74
packages/context/src/ingest/memory-flow/live-buffer.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import type {
|
||||
MemoryFlowEvent,
|
||||
MemoryFlowEventSink,
|
||||
MemoryFlowLiveBufferOptions,
|
||||
MemoryFlowReplayInput,
|
||||
MemoryFlowReplayPatch,
|
||||
MemoryFlowRunStatus,
|
||||
} from './types.js';
|
||||
|
||||
const URL_PATTERN = /\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi;
|
||||
const SECRET_ASSIGNMENT_PATTERN = /\b(password|passwd|pwd|token|api[_-]?key|secret)=([^\s&]+)/gi;
|
||||
|
||||
function copyReplayInput(input: MemoryFlowReplayInput): MemoryFlowReplayInput {
|
||||
return {
|
||||
...input,
|
||||
errors: [...input.errors],
|
||||
events: [...input.events],
|
||||
plannedWorkUnits: input.plannedWorkUnits.map((workUnit) => ({
|
||||
...workUnit,
|
||||
rawFiles: [...workUnit.rawFiles],
|
||||
})),
|
||||
details: {
|
||||
actions: input.details.actions.map((action) => ({ ...action, rawFiles: [...action.rawFiles] })),
|
||||
provenance: input.details.provenance.map((row) => ({ ...row })),
|
||||
transcripts: input.details.transcripts.map((summary) => ({ ...summary, toolNames: [...summary.toolNames] })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function notify(input: MemoryFlowReplayInput, options: MemoryFlowLiveBufferOptions): void {
|
||||
options.onChange?.(copyReplayInput(input));
|
||||
}
|
||||
|
||||
function stampEvent(event: MemoryFlowEvent, options: MemoryFlowLiveBufferOptions): MemoryFlowEvent {
|
||||
if (event.emittedAt) {
|
||||
return { ...event };
|
||||
}
|
||||
return { ...event, emittedAt: (options.now ?? (() => new Date()))().toISOString() };
|
||||
}
|
||||
|
||||
export function sanitizeMemoryFlowError(error: unknown): string {
|
||||
const raw = error instanceof Error ? error.message : String(error);
|
||||
return raw
|
||||
.replace(URL_PATTERN, (value) => `${value.slice(0, value.indexOf('://'))}://[redacted]`)
|
||||
.replace(SECRET_ASSIGNMENT_PATTERN, '$1=[redacted]');
|
||||
}
|
||||
|
||||
export function createMemoryFlowLiveBuffer(
|
||||
initialInput: MemoryFlowReplayInput,
|
||||
options: MemoryFlowLiveBufferOptions = {},
|
||||
): MemoryFlowEventSink {
|
||||
let input = copyReplayInput(initialInput);
|
||||
|
||||
return {
|
||||
emit(event: MemoryFlowEvent): void {
|
||||
input = { ...input, events: [...input.events, stampEvent(event, options)] };
|
||||
notify(input, options);
|
||||
},
|
||||
|
||||
update(patch: MemoryFlowReplayPatch): void {
|
||||
input = copyReplayInput({ ...input, ...patch });
|
||||
notify(input, options);
|
||||
},
|
||||
|
||||
finish(status: MemoryFlowRunStatus, errors: string[] = input.errors): void {
|
||||
input = copyReplayInput({ ...input, status, errors });
|
||||
notify(input, options);
|
||||
},
|
||||
|
||||
snapshot(): MemoryFlowReplayInput {
|
||||
return copyReplayInput(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('@klo/context/ingest/memory-flow lightweight export', () => {
|
||||
it('exports replay parsing and text rendering without the full ingest entry point', async () => {
|
||||
const memoryFlow = await import('./index.js');
|
||||
|
||||
expect(memoryFlow.parseMemoryFlowReplayInput).toBeTypeOf('function');
|
||||
expect(memoryFlow.buildMemoryFlowViewModel).toBeTypeOf('function');
|
||||
expect(memoryFlow.renderMemoryFlowReplay).toBeTypeOf('function');
|
||||
});
|
||||
});
|
||||
114
packages/context/src/ingest/memory-flow/render.test.ts
Normal file
114
packages/context/src/ingest/memory-flow/render.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { MemoryFlowViewModel } from './types.js';
|
||||
import { renderMemoryFlowReplay } from './render.js';
|
||||
|
||||
function view(): MemoryFlowViewModel {
|
||||
return {
|
||||
title: 'KLO memory flow warehouse/metricflow done',
|
||||
subtitle: 'Run run-1 Sync sync-1',
|
||||
status: 'done',
|
||||
activeLine: 'active: complete',
|
||||
selectedTitle: 'SOURCE',
|
||||
selectedDetails: ['Trigger: manual_resync', 'Adapter: metricflow'],
|
||||
completionLine:
|
||||
'Saved 2 memories from 2 raw files: 1 wiki pages, 1 SL updates. Commit: abc12345 Run: run-1 Report: report-1',
|
||||
trustIssues: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
columns: [
|
||||
{
|
||||
id: 'source',
|
||||
title: 'SOURCE',
|
||||
status: 'complete',
|
||||
headline: '2 raw files',
|
||||
counters: ['sync sync-1', 'scope none'],
|
||||
chips: [{ label: 'metricflow', status: 'complete' }],
|
||||
details: ['Trigger: manual_resync'],
|
||||
},
|
||||
{
|
||||
id: 'chunks',
|
||||
title: 'CHUNKS',
|
||||
status: 'complete',
|
||||
headline: '2 chunks',
|
||||
counters: ['+1 ~1 -0 =3', '0 deletions'],
|
||||
chips: [{ label: 'orders', status: 'complete' }],
|
||||
details: ['Work units planned: 2'],
|
||||
},
|
||||
{
|
||||
id: 'workUnits',
|
||||
title: 'WORKUNITS',
|
||||
status: 'warning',
|
||||
headline: '2 WUs',
|
||||
counters: ['1 done', '1 failed', '0 active'],
|
||||
chips: [{ label: 'orders', status: 'complete' }],
|
||||
details: ['orders: 1 raw, 1 peers, 1 deps'],
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
title: 'ACTIONS',
|
||||
status: 'complete',
|
||||
headline: '2 candidates',
|
||||
counters: ['1 wiki', '1 SL'],
|
||||
chips: [{ label: 'knowledge/orders.md', status: 'complete' }],
|
||||
details: ['wiki created: knowledge/orders.md'],
|
||||
},
|
||||
{
|
||||
id: 'gates',
|
||||
title: 'GATES',
|
||||
status: 'warning',
|
||||
headline: '1 conflict, 1 fallback',
|
||||
counters: ['1 failed', '1 flagged'],
|
||||
chips: [{ label: 'customers', status: 'failed' }],
|
||||
details: ['Failed work units: 1'],
|
||||
},
|
||||
{
|
||||
id: 'saved',
|
||||
title: 'SAVED',
|
||||
status: 'complete',
|
||||
headline: '2 memories',
|
||||
counters: ['1 wiki', '1 SL', '3 provenance'],
|
||||
chips: [{ label: 'abc12345', status: 'complete' }],
|
||||
details: ['Commit: abc12345'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('renderMemoryFlowReplay', () => {
|
||||
it('renders a six-column wide terminal snapshot', () => {
|
||||
expect(renderMemoryFlowReplay(view(), { terminalWidth: 140 })).toContain(
|
||||
'OK SOURCE -> OK CHUNKS -> !! WORKUNITS -> OK ACTIONS -> !! GATES -> OK SAVED',
|
||||
);
|
||||
expect(renderMemoryFlowReplay(view(), { terminalWidth: 140 })).toMatchInlineSnapshot(`
|
||||
"KLO memory flow warehouse/metricflow done
|
||||
active: complete
|
||||
Run run-1 Sync sync-1
|
||||
OK SOURCE -> OK CHUNKS -> !! WORKUNITS -> OK ACTIONS -> !! GATES -> OK SAVED
|
||||
|
||||
SOURCE CHUNKS WORKUNITS ACTIONS GATES SAVED
|
||||
2 raw files 2 chunks 2 WUs 2 candidates 1 conflict, 1 fallb 2 memories
|
||||
sync sync-1 +1 ~1 -0 =3 1 done 1 wiki 1 failed 1 wiki
|
||||
scope none 0 deletions 1 failed 1 SL 1 flagged 1 SL
|
||||
|
||||
Selected: SOURCE
|
||||
- Trigger: manual_resync
|
||||
- Adapter: metricflow
|
||||
|
||||
Saved 2 memories from 2 raw files: 1 wiki pages, 1 SL updates. Commit: abc12345 Run: run-1 Report: report-1
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders a stacked narrow terminal snapshot', () => {
|
||||
expect(renderMemoryFlowReplay(view(), { terminalWidth: 72 })).toContain(
|
||||
'OK SOURCE -> OK CHUNKS -> !! WORKUNITS -> OK ACTIONS -> !! GATES -> OK SAVED',
|
||||
);
|
||||
expect(renderMemoryFlowReplay(view(), { terminalWidth: 72 })).toContain(`SOURCE
|
||||
2 raw files
|
||||
sync sync-1
|
||||
scope none`);
|
||||
expect(renderMemoryFlowReplay(view(), { terminalWidth: 72 })).toContain(`GATES
|
||||
1 conflict, 1 fallback
|
||||
1 failed
|
||||
1 flagged`);
|
||||
});
|
||||
});
|
||||
99
packages/context/src/ingest/memory-flow/render.ts
Normal file
99
packages/context/src/ingest/memory-flow/render.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { MemoryFlowColumnView, MemoryFlowRenderOptions, MemoryFlowViewModel } from './types.js';
|
||||
import { renderMemoryFlowConnectorLine } from './visuals.js';
|
||||
|
||||
const WIDE_COLUMN_WIDTH = 20;
|
||||
|
||||
function cell(value: string | undefined, width = WIDE_COLUMN_WIDTH): string {
|
||||
const text = value ?? '';
|
||||
const normalized = text.length > width ? text.slice(0, width - 1) : text;
|
||||
return normalized.padEnd(width, ' ');
|
||||
}
|
||||
|
||||
function row(values: string[]): string {
|
||||
return values.map((value) => cell(value)).join(' ').trimEnd();
|
||||
}
|
||||
|
||||
function counterAt(column: MemoryFlowColumnView, index: number): string {
|
||||
return column.counters[index] ?? '';
|
||||
}
|
||||
|
||||
function trustIssueLines(view: MemoryFlowViewModel): string[] {
|
||||
if (view.trustIssues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Trust issues',
|
||||
...view.trustIssues.slice(0, 4).map((issue) => {
|
||||
const label = issue.severity === 'failed' ? 'FAILED' : 'WARNING';
|
||||
return `${label} ${issue.title}: ${issue.detail}`;
|
||||
}),
|
||||
...(view.trustIssues.length > 4 ? [`+${view.trustIssues.length - 4} more trust issues`] : []),
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
function renderWide(view: MemoryFlowViewModel): string {
|
||||
const lines = [
|
||||
view.title,
|
||||
view.activeLine,
|
||||
view.subtitle,
|
||||
renderMemoryFlowConnectorLine(view),
|
||||
...trustIssueLines(view),
|
||||
'',
|
||||
row(view.columns.map((column) => column.title)),
|
||||
row(view.columns.map((column) => column.headline)),
|
||||
row(view.columns.map((column) => counterAt(column, 0))),
|
||||
row(view.columns.map((column) => counterAt(column, 1))),
|
||||
'',
|
||||
`Selected: ${view.selectedTitle}`,
|
||||
...view.selectedDetails.map((detail) => `- ${detail}`),
|
||||
];
|
||||
|
||||
if (view.completionLine) {
|
||||
lines.push('', view.completionLine);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderNarrowColumn(column: MemoryFlowColumnView): string[] {
|
||||
return [
|
||||
column.title,
|
||||
` ${column.headline}`,
|
||||
...column.counters.slice(0, 3).map((counter) => ` ${counter}`),
|
||||
];
|
||||
}
|
||||
|
||||
function renderNarrow(view: MemoryFlowViewModel): string {
|
||||
const lines = [
|
||||
view.title,
|
||||
view.activeLine,
|
||||
view.subtitle,
|
||||
renderMemoryFlowConnectorLine(view),
|
||||
...trustIssueLines(view),
|
||||
'',
|
||||
...view.columns.flatMap((column, index) => [
|
||||
...(index > 0 ? [''] : []),
|
||||
...renderNarrowColumn(column),
|
||||
]),
|
||||
'',
|
||||
`Selected: ${view.selectedTitle}`,
|
||||
...view.selectedDetails.map((detail) => `- ${detail}`),
|
||||
];
|
||||
|
||||
if (view.completionLine) {
|
||||
lines.push('', view.completionLine);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function renderMemoryFlowReplay(view: MemoryFlowViewModel, options: MemoryFlowRenderOptions = {}): string {
|
||||
if ((options.terminalWidth ?? 120) < 100) {
|
||||
return renderNarrow(view);
|
||||
}
|
||||
return renderWide(view);
|
||||
}
|
||||
164
packages/context/src/ingest/memory-flow/schema.test.ts
Normal file
164
packages/context/src/ingest/memory-flow/schema.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
memoryFlowReplayInputSchema,
|
||||
memoryFlowStreamEventSchema,
|
||||
parseMemoryFlowReplayInput,
|
||||
} from './schema.js';
|
||||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
|
||||
function snapshot(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'job-1',
|
||||
connectionId: 'connection-1',
|
||||
adapter: 'metabase',
|
||||
status: 'running',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-1',
|
||||
errors: [],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metabase', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'scope_detected', fingerprint: 'scope-1' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-1', rawFileCount: 2 },
|
||||
{ type: 'diff_computed', added: 1, modified: 1, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'work_unit_step', unitKey: 'orders', stepIndex: 1, stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'wiki', action: 'created', key: 'knowledge/orders.md' },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'abc12345', wikiCount: 1, slCount: 0 },
|
||||
{ type: 'provenance_recorded', rowCount: 1 },
|
||||
{ type: 'report_created', runId: 'run-1', reportPath: 'ingest-report.json' },
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders.md'], peerFileCount: 0, dependencyCount: 1 }],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/orders.md',
|
||||
summary: 'Created orders page',
|
||||
rawFiles: ['orders.md'],
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
provenance: [
|
||||
{
|
||||
rawPath: 'orders.md',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: 'transcripts/orders.jsonl',
|
||||
toolCallCount: 2,
|
||||
errorCount: 0,
|
||||
toolNames: ['wiki_write'],
|
||||
},
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('memory-flow schemas', () => {
|
||||
it('parses a full replay input snapshot', () => {
|
||||
expect(parseMemoryFlowReplayInput(snapshot())).toEqual(snapshot());
|
||||
});
|
||||
|
||||
it('parses replay metadata and timestamped events', () => {
|
||||
const parsed = parseMemoryFlowReplayInput(
|
||||
snapshot({
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'reports/report-1.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: 'source_acquired',
|
||||
adapter: 'metabase',
|
||||
trigger: 'manual_resync',
|
||||
fileCount: 2,
|
||||
emittedAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.metadata).toEqual({
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'captured',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'reports/report-1.json',
|
||||
fallbackReason: null,
|
||||
});
|
||||
expect(parsed.events).toEqual([
|
||||
{
|
||||
type: 'source_acquired',
|
||||
adapter: 'metabase',
|
||||
trigger: 'manual_resync',
|
||||
fileCount: 2,
|
||||
emittedAt: '2026-05-01T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses skipped deterministic stages', () => {
|
||||
const parsed = parseMemoryFlowReplayInput(
|
||||
snapshot({
|
||||
status: 'done',
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_deterministic', fileCount: 7 },
|
||||
{ type: 'scope_detected', fingerprint: 'sqlite' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-demo', rawFileCount: 7 },
|
||||
{ type: 'diff_computed', added: 7, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 7, workUnitCount: 0, evictionCount: 0 },
|
||||
{ type: 'stage_skipped', stage: 'workUnits', reason: 'deterministic mode' },
|
||||
{ type: 'stage_skipped', stage: 'actions', reason: 'requires LLM' },
|
||||
{ type: 'stage_skipped', stage: 'gates', reason: 'requires candidate actions' },
|
||||
{ type: 'stage_skipped', stage: 'saved', reason: 'requires LLM memory synthesis' },
|
||||
{ type: 'saved', commitSha: null, wikiCount: 0, slCount: 0 },
|
||||
{ type: 'provenance_recorded', rowCount: 0 },
|
||||
{
|
||||
type: 'report_created',
|
||||
runId: 'scan-demo',
|
||||
reportPath: 'raw-sources/orbit_demo/live-database/sync-demo/scan-report.json',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.events).toContainEqual({ type: 'stage_skipped', stage: 'workUnits', reason: 'deterministic mode' });
|
||||
expect(parsed.events).toContainEqual({ type: 'stage_skipped', stage: 'actions', reason: 'requires LLM' });
|
||||
});
|
||||
|
||||
it('parses snapshot and closed stream events', () => {
|
||||
expect(memoryFlowStreamEventSchema.parse({ type: 'snapshot', snapshot: snapshot({ status: 'done' }) })).toEqual({
|
||||
type: 'snapshot',
|
||||
snapshot: snapshot({ status: 'done' }),
|
||||
});
|
||||
|
||||
expect(memoryFlowStreamEventSchema.parse({ type: 'closed', status: 'done', errors: [] })).toEqual({
|
||||
type: 'closed',
|
||||
status: 'done',
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid replay status values', () => {
|
||||
expect(() => memoryFlowReplayInputSchema.parse({ ...snapshot(), status: 'complete' })).toThrow();
|
||||
});
|
||||
});
|
||||
171
packages/context/src/ingest/memory-flow/schema.ts
Normal file
171
packages/context/src/ingest/memory-flow/schema.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import * as z from 'zod';
|
||||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
|
||||
export const memoryFlowRunStatusSchema = z.enum(['running', 'done', 'error']);
|
||||
|
||||
const memoryFlowEventTimestampShape = {
|
||||
emittedAt: z.string().datetime().optional(),
|
||||
};
|
||||
|
||||
function eventSchema<T extends z.ZodRawShape>(shape: T): z.ZodObject<T & typeof memoryFlowEventTimestampShape> {
|
||||
return z.object({ ...shape, ...memoryFlowEventTimestampShape });
|
||||
}
|
||||
|
||||
const memoryFlowReplayMetadataSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
mode: z.enum(['full', 'deterministic', 'replay', 'seeded']),
|
||||
origin: z.enum(['captured', 'packaged', 'synthetic-report']),
|
||||
timing: z.enum(['captured', 'synthetic', 'not-captured', 'prebuilt']),
|
||||
capturedAt: z.string().datetime().nullable(),
|
||||
sourceReportId: z.string().min(1).nullable(),
|
||||
sourceReportPath: z.string().min(1).nullable(),
|
||||
fallbackReason: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
export const memoryFlowEventSchema = z.discriminatedUnion('type', [
|
||||
eventSchema({
|
||||
type: z.literal('source_acquired'),
|
||||
adapter: z.string().min(1),
|
||||
trigger: z.string().min(1),
|
||||
fileCount: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({ type: z.literal('scope_detected'), fingerprint: z.string().nullable() }),
|
||||
eventSchema({
|
||||
type: z.literal('raw_snapshot_written'),
|
||||
syncId: z.string().min(1),
|
||||
rawFileCount: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('diff_computed'),
|
||||
added: z.number().int().min(0),
|
||||
modified: z.number().int().min(0),
|
||||
deleted: z.number().int().min(0),
|
||||
unchanged: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('chunks_planned'),
|
||||
chunkCount: z.number().int().min(0),
|
||||
workUnitCount: z.number().int().min(0),
|
||||
evictionCount: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('stage_skipped'),
|
||||
stage: z.enum(['source', 'chunks', 'workUnits', 'actions', 'gates', 'saved']),
|
||||
reason: z.string().min(1),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('work_unit_started'),
|
||||
unitKey: z.string().min(1),
|
||||
skills: z.array(z.string().min(1)),
|
||||
stepBudget: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('work_unit_step'),
|
||||
unitKey: z.string().min(1),
|
||||
stepIndex: z.number().int().min(0),
|
||||
stepBudget: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('candidate_action'),
|
||||
unitKey: z.string().min(1),
|
||||
target: z.enum(['wiki', 'sl']),
|
||||
action: z.string().min(1),
|
||||
key: z.string().min(1),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('work_unit_finished'),
|
||||
unitKey: z.string().min(1),
|
||||
status: z.enum(['success', 'failed']),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('reconciliation_finished'),
|
||||
conflictCount: z.number().int().min(0),
|
||||
fallbackCount: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({
|
||||
type: z.literal('saved'),
|
||||
commitSha: z.string().nullable(),
|
||||
wikiCount: z.number().int().min(0),
|
||||
slCount: z.number().int().min(0),
|
||||
}),
|
||||
eventSchema({ type: z.literal('provenance_recorded'), rowCount: z.number().int().min(0) }),
|
||||
eventSchema({
|
||||
type: z.literal('report_created'),
|
||||
runId: z.string().min(1),
|
||||
reportPath: z.string().min(1).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const memoryFlowPlannedWorkUnitSchema = z.object({
|
||||
unitKey: z.string().min(1),
|
||||
rawFiles: z.array(z.string()),
|
||||
peerFileCount: z.number().int().min(0),
|
||||
dependencyCount: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const memoryFlowActionDetailSchema = z.object({
|
||||
unitKey: z.string().min(1),
|
||||
target: z.enum(['wiki', 'sl']),
|
||||
action: z.enum(['created', 'updated', 'removed']),
|
||||
key: z.string().min(1),
|
||||
summary: z.string(),
|
||||
rawFiles: z.array(z.string()),
|
||||
status: z.enum(['success', 'failed']),
|
||||
});
|
||||
|
||||
const memoryFlowProvenanceDetailSchema = z.object({
|
||||
rawPath: z.string(),
|
||||
artifactKind: z.enum(['sl', 'wiki']).nullable(),
|
||||
artifactKey: z.string().nullable(),
|
||||
actionType: z.string().min(1),
|
||||
});
|
||||
|
||||
const memoryFlowTranscriptDetailSchema = z.object({
|
||||
unitKey: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
toolCallCount: z.number().int().min(0),
|
||||
errorCount: z.number().int().min(0),
|
||||
toolNames: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const memoryFlowDetailSectionsSchema = z.object({
|
||||
actions: z.array(memoryFlowActionDetailSchema),
|
||||
provenance: z.array(memoryFlowProvenanceDetailSchema),
|
||||
transcripts: z.array(memoryFlowTranscriptDetailSchema),
|
||||
});
|
||||
|
||||
export const memoryFlowReplayInputSchema: z.ZodType<MemoryFlowReplayInput> = z.object({
|
||||
metadata: memoryFlowReplayMetadataSchema.optional(),
|
||||
runId: z.string().min(1),
|
||||
connectionId: z.string().min(1),
|
||||
adapter: z.string().min(1),
|
||||
status: memoryFlowRunStatusSchema,
|
||||
sourceDir: z.string().nullable(),
|
||||
syncId: z.string().min(1),
|
||||
reportId: z.string().min(1).optional(),
|
||||
reportPath: z.string().min(1).optional(),
|
||||
errors: z.array(z.string()),
|
||||
events: z.array(memoryFlowEventSchema),
|
||||
plannedWorkUnits: z.array(memoryFlowPlannedWorkUnitSchema),
|
||||
details: memoryFlowDetailSectionsSchema,
|
||||
});
|
||||
|
||||
export const memoryFlowStreamEventSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('snapshot'), snapshot: memoryFlowReplayInputSchema }),
|
||||
z.object({
|
||||
type: z.literal('closed'),
|
||||
status: memoryFlowRunStatusSchema,
|
||||
errors: z.array(z.string()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type MemoryFlowStreamEvent = z.infer<typeof memoryFlowStreamEventSchema>;
|
||||
|
||||
export function parseMemoryFlowReplayInput(value: unknown): MemoryFlowReplayInput {
|
||||
const result = memoryFlowReplayInputSchema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid memory-flow replay input: ${z.prettifyError(result.error)}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
125
packages/context/src/ingest/memory-flow/summary.test.ts
Normal file
125
packages/context/src/ingest/memory-flow/summary.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
import { formatMemoryFlowFinalSummary } from './summary.js';
|
||||
|
||||
function input(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'metricflow',
|
||||
status: 'done',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-1',
|
||||
errors: [],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders.yml'], peerFileCount: 0, dependencyCount: 0 }],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'saved', commitSha: 'abc12345', wikiCount: 1, slCount: 1 },
|
||||
{ type: 'provenance_recorded', rowCount: 2 },
|
||||
{ type: 'report_created', runId: 'run-1', reportPath: 'report-1' },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('formatMemoryFlowFinalSummary', () => {
|
||||
it('summarizes a successful full memory-flow run', () => {
|
||||
expect(formatMemoryFlowFinalSummary(input())).toBe(
|
||||
[
|
||||
'Memory-flow summary: done',
|
||||
'Connection: warehouse',
|
||||
'Adapter: metricflow',
|
||||
'Run: run-1',
|
||||
'Sync: sync-1',
|
||||
'Source files: 2',
|
||||
'Table reviews: 1 total, 1 done, 0 failed',
|
||||
'Saved memory: 1 wiki, 1 semantic layer',
|
||||
'Provenance rows: 2',
|
||||
'Report: report-1',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('includes trust issues and sanitized errors for failed runs', () => {
|
||||
expect(
|
||||
formatMemoryFlowFinalSummary(
|
||||
input({
|
||||
status: 'error',
|
||||
errors: ['failed token=secret'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'failed', reason: 'validation failed token=secret' },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toContain('Trust issues: 3');
|
||||
});
|
||||
|
||||
it('labels replay source metadata in final summaries', () => {
|
||||
const summary = formatMemoryFlowFinalSummary({
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'replay',
|
||||
origin: 'packaged',
|
||||
timing: 'captured',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'demo-replay-report',
|
||||
sourceReportPath: 'replays/replay.memory-flow.v1.json',
|
||||
fallbackReason: null,
|
||||
},
|
||||
runId: 'demo-replay-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'demo-replay-sync',
|
||||
reportPath: 'replays/replay.memory-flow.v1.json',
|
||||
errors: [],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_replay', fileCount: 7 },
|
||||
{ type: 'saved', commitSha: null, wikiCount: 3, slCount: 2 },
|
||||
{ type: 'provenance_recorded', rowCount: 5 },
|
||||
{ type: 'report_created', runId: 'demo-replay-orbit', reportPath: 'replays/replay.memory-flow.v1.json' },
|
||||
],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
});
|
||||
|
||||
expect(summary).toContain('Replay source: packaged replay (captured timing)');
|
||||
expect(summary).toContain('Replay captured: 2026-05-01T10:00:03.000Z');
|
||||
});
|
||||
|
||||
it('labels synthetic report replays with the reconstruction reason', () => {
|
||||
const summary = formatMemoryFlowFinalSummary({
|
||||
metadata: {
|
||||
schemaVersion: 1,
|
||||
mode: 'full',
|
||||
origin: 'synthetic-report',
|
||||
timing: 'synthetic',
|
||||
capturedAt: '2026-05-01T10:00:03.000Z',
|
||||
sourceReportId: 'report-1',
|
||||
sourceReportPath: 'report-1',
|
||||
fallbackReason: 'report did not include captured memory-flow events',
|
||||
},
|
||||
runId: 'run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'lookml',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'sync-1',
|
||||
reportPath: 'report-1',
|
||||
errors: [],
|
||||
events: [{ type: 'report_created', runId: 'run-1', reportPath: 'report-1' }],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
});
|
||||
|
||||
expect(summary).toContain('Replay source: synthetic report replay (synthetic timing)');
|
||||
expect(summary).toContain('Replay note: report did not include captured memory-flow events');
|
||||
});
|
||||
});
|
||||
93
packages/context/src/ingest/memory-flow/summary.ts
Normal file
93
packages/context/src/ingest/memory-flow/summary.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { sanitizeMemoryFlowError } from './live-buffer.js';
|
||||
import type { MemoryFlowEvent, MemoryFlowReplayInput } from './types.js';
|
||||
import { buildMemoryFlowViewModel } from './view-model.js';
|
||||
|
||||
function latest<T extends MemoryFlowEvent['type']>(
|
||||
events: MemoryFlowEvent[],
|
||||
type: T,
|
||||
): Extract<MemoryFlowEvent, { type: T }> | undefined {
|
||||
return events.filter((event): event is Extract<MemoryFlowEvent, { type: T }> => event.type === type).at(-1);
|
||||
}
|
||||
|
||||
function eventsOf<T extends MemoryFlowEvent['type']>(
|
||||
events: MemoryFlowEvent[],
|
||||
type: T,
|
||||
): Array<Extract<MemoryFlowEvent, { type: T }>> {
|
||||
return events.filter((event): event is Extract<MemoryFlowEvent, { type: T }> => event.type === type);
|
||||
}
|
||||
|
||||
function replaySourceLine(input: MemoryFlowReplayInput): string | null {
|
||||
const metadata = input.metadata;
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const origin =
|
||||
metadata.origin === 'synthetic-report'
|
||||
? 'synthetic report replay'
|
||||
: metadata.origin === 'packaged'
|
||||
? 'packaged replay'
|
||||
: 'captured replay';
|
||||
return `Replay source: ${origin} (${metadata.timing} timing)`;
|
||||
}
|
||||
|
||||
function humanizeSummaryText(value: string): string {
|
||||
return value
|
||||
.replace(/\bWORKUNITS\b/g, 'PLAN')
|
||||
.replace(/\bWorkUnit\b/g, 'Table review')
|
||||
.replace(/\bwork units\b/gi, 'table reviews')
|
||||
.replace(/\bWUs\b/g, 'tables')
|
||||
.replace(/\braw files\b/gi, 'database files')
|
||||
.replace(/\braw file\b/gi, 'database file')
|
||||
.replace(/\bSL\b/g, 'semantic layer');
|
||||
}
|
||||
|
||||
export function formatMemoryFlowFinalSummary(input: MemoryFlowReplayInput): string {
|
||||
const sources = eventsOf(input.events, 'source_acquired');
|
||||
const source = sources.at(-1);
|
||||
const totalFiles = sources.reduce((sum, s) => sum + s.fileCount, 0);
|
||||
const saved = latest(input.events, 'saved');
|
||||
const provenance = latest(input.events, 'provenance_recorded');
|
||||
const report = latest(input.events, 'report_created');
|
||||
const finished = eventsOf(input.events, 'work_unit_finished');
|
||||
const failed = finished.filter((event) => event.status === 'failed');
|
||||
const view = buildMemoryFlowViewModel(input);
|
||||
const lines = [
|
||||
`Memory-flow summary: ${input.status}`,
|
||||
`Connection: ${input.connectionId}`,
|
||||
...(sources.length > 1
|
||||
? [`Sources: ${[...new Set(sources.map((s) => s.adapter))].join(', ')}`]
|
||||
: [`Adapter: ${input.adapter}`]),
|
||||
`Run: ${input.runId}`,
|
||||
`Sync: ${input.syncId}`,
|
||||
`Source files: ${totalFiles}`,
|
||||
`Table reviews: ${input.plannedWorkUnits.length || finished.length} total, ${finished.length - failed.length} done, ${failed.length} failed`,
|
||||
`Saved memory: ${saved?.wikiCount ?? 0} wiki, ${saved?.slCount ?? 0} semantic layer`,
|
||||
`Provenance rows: ${provenance?.rowCount ?? 0}`,
|
||||
`Report: ${report?.reportPath ?? input.reportPath ?? 'none'}`,
|
||||
];
|
||||
const sourceLine = replaySourceLine(input);
|
||||
if (sourceLine) {
|
||||
lines.push(sourceLine);
|
||||
}
|
||||
if (input.metadata?.capturedAt) {
|
||||
lines.push(`Replay captured: ${input.metadata.capturedAt}`);
|
||||
}
|
||||
if (input.metadata?.fallbackReason) {
|
||||
lines.push(`Replay note: ${input.metadata.fallbackReason}`);
|
||||
}
|
||||
|
||||
if (view.trustIssues.length > 0) {
|
||||
lines.push(`Trust issues: ${view.trustIssues.length}`);
|
||||
for (const issue of view.trustIssues.slice(0, 3)) {
|
||||
lines.push(`- ${humanizeSummaryText(issue.title)}: ${humanizeSummaryText(issue.detail)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const error of input.errors.slice(0, 3)) {
|
||||
lines.push(`Error: ${sanitizeMemoryFlowError(error)}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
246
packages/context/src/ingest/memory-flow/types.ts
Normal file
246
packages/context/src/ingest/memory-flow/types.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
type MemoryFlowReplayMode = 'full' | 'deterministic' | 'replay' | 'seeded';
|
||||
type MemoryFlowReplayOrigin = 'captured' | 'packaged' | 'synthetic-report';
|
||||
type MemoryFlowReplayTiming = 'captured' | 'synthetic' | 'not-captured' | 'prebuilt';
|
||||
|
||||
interface MemoryFlowReplayMetadata {
|
||||
schemaVersion: 1;
|
||||
mode: MemoryFlowReplayMode;
|
||||
origin: MemoryFlowReplayOrigin;
|
||||
timing: MemoryFlowReplayTiming;
|
||||
capturedAt: string | null;
|
||||
sourceReportId: string | null;
|
||||
sourceReportPath: string | null;
|
||||
fallbackReason: string | null;
|
||||
}
|
||||
|
||||
type MemoryFlowEventPayload =
|
||||
| {
|
||||
type: 'source_acquired';
|
||||
adapter: string;
|
||||
trigger: string;
|
||||
fileCount: number;
|
||||
}
|
||||
| { type: 'scope_detected'; fingerprint: string | null }
|
||||
| {
|
||||
type: 'raw_snapshot_written';
|
||||
syncId: string;
|
||||
rawFileCount: number;
|
||||
}
|
||||
| {
|
||||
type: 'diff_computed';
|
||||
added: number;
|
||||
modified: number;
|
||||
deleted: number;
|
||||
unchanged: number;
|
||||
}
|
||||
| {
|
||||
type: 'chunks_planned';
|
||||
chunkCount: number;
|
||||
workUnitCount: number;
|
||||
evictionCount: number;
|
||||
}
|
||||
| {
|
||||
type: 'stage_skipped';
|
||||
stage: MemoryFlowColumnId;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'work_unit_started';
|
||||
unitKey: string;
|
||||
skills: string[];
|
||||
stepBudget: number;
|
||||
}
|
||||
| {
|
||||
type: 'work_unit_step';
|
||||
unitKey: string;
|
||||
stepIndex: number;
|
||||
stepBudget: number;
|
||||
}
|
||||
| {
|
||||
type: 'candidate_action';
|
||||
unitKey: string;
|
||||
target: 'wiki' | 'sl';
|
||||
action: string;
|
||||
key: string;
|
||||
}
|
||||
| {
|
||||
type: 'work_unit_finished';
|
||||
unitKey: string;
|
||||
status: 'success' | 'failed';
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
type: 'reconciliation_finished';
|
||||
conflictCount: number;
|
||||
fallbackCount: number;
|
||||
}
|
||||
| {
|
||||
type: 'saved';
|
||||
commitSha: string | null;
|
||||
wikiCount: number;
|
||||
slCount: number;
|
||||
}
|
||||
| { type: 'provenance_recorded'; rowCount: number }
|
||||
| { type: 'report_created'; runId: string; reportPath?: string };
|
||||
|
||||
export type MemoryFlowEvent = MemoryFlowEventPayload & { emittedAt?: string };
|
||||
|
||||
export type MemoryFlowRunStatus = 'running' | 'done' | 'error';
|
||||
|
||||
export interface MemoryFlowPlannedWorkUnit {
|
||||
unitKey: string;
|
||||
rawFiles: string[];
|
||||
peerFileCount: number;
|
||||
dependencyCount: number;
|
||||
}
|
||||
|
||||
export interface MemoryFlowActionDetail {
|
||||
unitKey: string;
|
||||
target: 'wiki' | 'sl';
|
||||
action: 'created' | 'updated' | 'removed';
|
||||
key: string;
|
||||
summary: string;
|
||||
rawFiles: string[];
|
||||
status: 'success' | 'failed';
|
||||
}
|
||||
|
||||
interface MemoryFlowProvenanceDetail {
|
||||
rawPath: string;
|
||||
artifactKind: 'sl' | 'wiki' | null;
|
||||
artifactKey: string | null;
|
||||
actionType: string;
|
||||
}
|
||||
|
||||
interface MemoryFlowTranscriptDetail {
|
||||
unitKey: string;
|
||||
path: string;
|
||||
toolCallCount: number;
|
||||
errorCount: number;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
export interface MemoryFlowDetailSections {
|
||||
actions: MemoryFlowActionDetail[];
|
||||
provenance: MemoryFlowProvenanceDetail[];
|
||||
transcripts: MemoryFlowTranscriptDetail[];
|
||||
}
|
||||
|
||||
export interface MemoryFlowReplayInput {
|
||||
metadata?: MemoryFlowReplayMetadata;
|
||||
runId: string;
|
||||
connectionId: string;
|
||||
adapter: string;
|
||||
status: MemoryFlowRunStatus;
|
||||
sourceDir: string | null;
|
||||
syncId: string;
|
||||
reportId?: string;
|
||||
reportPath?: string;
|
||||
errors: string[];
|
||||
events: MemoryFlowEvent[];
|
||||
plannedWorkUnits: MemoryFlowPlannedWorkUnit[];
|
||||
details: MemoryFlowDetailSections;
|
||||
}
|
||||
|
||||
export type MemoryFlowReplayPatch = Partial<Omit<MemoryFlowReplayInput, 'events'>>;
|
||||
|
||||
export interface MemoryFlowEventSink {
|
||||
emit(event: MemoryFlowEvent): void;
|
||||
update(patch: MemoryFlowReplayPatch): void;
|
||||
finish(status: MemoryFlowRunStatus, errors?: string[]): void;
|
||||
snapshot(): MemoryFlowReplayInput;
|
||||
}
|
||||
|
||||
export interface MemoryFlowLiveBufferOptions {
|
||||
onChange?(snapshot: MemoryFlowReplayInput): void;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export type MemoryFlowColumnId = 'source' | 'chunks' | 'workUnits' | 'actions' | 'gates' | 'saved';
|
||||
export type MemoryFlowDisplayStatus = 'waiting' | 'active' | 'complete' | 'warning' | 'failed';
|
||||
|
||||
export interface MemoryFlowChip {
|
||||
label: string;
|
||||
status: MemoryFlowDisplayStatus;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface MemoryFlowColumnView {
|
||||
id: MemoryFlowColumnId;
|
||||
title: string;
|
||||
status: MemoryFlowDisplayStatus;
|
||||
headline: string;
|
||||
counters: string[];
|
||||
chips: MemoryFlowChip[];
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export interface MemoryFlowTrustIssue {
|
||||
id: string;
|
||||
severity: 'warning' | 'failed';
|
||||
title: string;
|
||||
detail: string;
|
||||
columnId: MemoryFlowColumnId;
|
||||
targetLabel?: string;
|
||||
}
|
||||
|
||||
export interface MemoryFlowSearchMatch {
|
||||
columnId: MemoryFlowColumnId;
|
||||
chipIndex?: number;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MemoryFlowViewModel {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: MemoryFlowRunStatus;
|
||||
activeLine: string;
|
||||
columns: MemoryFlowColumnView[];
|
||||
trustIssues: MemoryFlowTrustIssue[];
|
||||
selectedTitle: string;
|
||||
selectedDetails: string[];
|
||||
completionLine: string | null;
|
||||
details: MemoryFlowDetailSections;
|
||||
}
|
||||
|
||||
export interface MemoryFlowRenderOptions {
|
||||
terminalWidth?: number;
|
||||
}
|
||||
|
||||
export type MemoryFlowPaneId = 'overview' | 'trust' | 'details' | 'log' | 'provenance' | 'transcript';
|
||||
export type MemoryFlowFilterMode = 'all' | 'failed_or_flagged';
|
||||
|
||||
interface MemoryFlowSearchState {
|
||||
editing: boolean;
|
||||
query: string;
|
||||
matchIndex: number;
|
||||
}
|
||||
|
||||
export interface MemoryFlowInteractionState {
|
||||
selectedColumnId: MemoryFlowColumnId;
|
||||
selectedChipIndex: number;
|
||||
expanded: boolean;
|
||||
pane: MemoryFlowPaneId;
|
||||
filter: MemoryFlowFilterMode;
|
||||
search: MemoryFlowSearchState;
|
||||
shouldQuit: boolean;
|
||||
}
|
||||
|
||||
export type MemoryFlowInteractionCommand =
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'enter'
|
||||
| 'tab'
|
||||
| 'filter'
|
||||
| 'provenance'
|
||||
| 'transcript'
|
||||
| 'search-start'
|
||||
| 'search-submit'
|
||||
| 'search-backspace'
|
||||
| 'search-clear'
|
||||
| 'search-next'
|
||||
| 'search-previous'
|
||||
| 'quit'
|
||||
| { type: 'search-input'; value: string };
|
||||
436
packages/context/src/ingest/memory-flow/view-model.test.ts
Normal file
436
packages/context/src/ingest/memory-flow/view-model.test.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { MemoryFlowReplayInput } from './types.js';
|
||||
import { buildMemoryFlowViewModel } from './view-model.js';
|
||||
|
||||
function replayInput(): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'metricflow',
|
||||
status: 'done',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-1',
|
||||
errors: [],
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'orders', rawFiles: ['orders.yml'], peerFileCount: 1, dependencyCount: 1 },
|
||||
{ unitKey: 'revenue', rawFiles: ['revenue.yml'], peerFileCount: 0, dependencyCount: 0 },
|
||||
],
|
||||
details: {
|
||||
actions: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/orders.md',
|
||||
summary: 'order facts',
|
||||
rawFiles: ['orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
unitKey: 'orders',
|
||||
target: 'sl',
|
||||
action: 'updated',
|
||||
key: 'warehouse.orders',
|
||||
summary: 'order measures',
|
||||
rawFiles: ['orders.yml'],
|
||||
status: 'success',
|
||||
},
|
||||
],
|
||||
provenance: [
|
||||
{
|
||||
rawPath: 'orders.yml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
],
|
||||
transcripts: [
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: '/tmp/transcripts/orders.jsonl',
|
||||
toolCallCount: 3,
|
||||
errorCount: 0,
|
||||
toolNames: ['read_raw_span', 'wiki_write', 'sl_write_source'],
|
||||
},
|
||||
],
|
||||
},
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'scope_detected', fingerprint: 'scope-abc' },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-1', rawFileCount: 2 },
|
||||
{ type: 'diff_computed', added: 1, modified: 1, deleted: 0, unchanged: 3 },
|
||||
{ type: 'chunks_planned', chunkCount: 2, workUnitCount: 2, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'wiki', action: 'created', key: 'knowledge/orders.md' },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'sl', action: 'updated', key: 'warehouse.orders' },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'work_unit_finished', unitKey: 'revenue', status: 'failed', reason: 'validation failed' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 1, fallbackCount: 1 },
|
||||
{ type: 'saved', commitSha: 'abc123456789', wikiCount: 1, slCount: 1 }, // pragma: allowlist secret
|
||||
{ type: 'provenance_recorded', rowCount: 3 },
|
||||
{ type: 'report_created', runId: 'run-1', reportPath: 'report-1' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function baseReplayInput(overrides: Partial<MemoryFlowReplayInput> = {}): MemoryFlowReplayInput {
|
||||
return {
|
||||
runId: 'run-errors',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'metricflow',
|
||||
status: 'error',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-errors',
|
||||
errors: [],
|
||||
events: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildMemoryFlowViewModel', () => {
|
||||
it('builds six readable columns from replay events', () => {
|
||||
const view = buildMemoryFlowViewModel(replayInput());
|
||||
|
||||
expect(view.title).toBe('KLO memory flow warehouse/metricflow done');
|
||||
expect(view.activeLine).toBe('active: complete');
|
||||
expect(view.columns.map((column) => column.id)).toEqual([
|
||||
'source',
|
||||
'chunks',
|
||||
'workUnits',
|
||||
'actions',
|
||||
'gates',
|
||||
'saved',
|
||||
]);
|
||||
expect(view.columns.map((column) => column.headline)).toEqual([
|
||||
'2 raw files',
|
||||
'2 chunks',
|
||||
'2 WUs',
|
||||
'2 candidates',
|
||||
'1 conflict, 1 fallback',
|
||||
'2 memories',
|
||||
]);
|
||||
expect(view.columns.find((column) => column.id === 'workUnits')?.counters).toEqual([
|
||||
'1 done',
|
||||
'1 failed',
|
||||
'0 active',
|
||||
]);
|
||||
expect(view.columns.find((column) => column.id === 'actions')?.counters).toEqual(['1 wiki', '1 SL']);
|
||||
expect(view.details.actions).toHaveLength(2);
|
||||
expect(view.details.provenance).toEqual([
|
||||
{
|
||||
rawPath: 'orders.yml',
|
||||
artifactKind: 'wiki',
|
||||
artifactKey: 'knowledge/orders.md',
|
||||
actionType: 'wiki_written',
|
||||
},
|
||||
]);
|
||||
expect(view.details.transcripts).toEqual([
|
||||
{
|
||||
unitKey: 'orders',
|
||||
path: '/tmp/transcripts/orders.jsonl',
|
||||
toolCallCount: 3,
|
||||
errorCount: 0,
|
||||
toolNames: ['read_raw_span', 'wiki_write', 'sl_write_source'],
|
||||
},
|
||||
]);
|
||||
expect(view.columns.find((column) => column.id === 'actions')?.details).toContain(
|
||||
'orders wiki created knowledge/orders.md: order facts',
|
||||
);
|
||||
expect(view.columns.find((column) => column.id === 'saved')?.details).toContain('Commit: abc12345');
|
||||
expect(view.completionLine).toBe(
|
||||
'Saved 2 memories from 2 raw files: 1 wiki pages, 1 SL updates. Commit: abc12345 Run: run-1 Report: report-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows all seeded demo source families and sums raw files in the completion line', () => {
|
||||
const view = buildMemoryFlowViewModel({
|
||||
runId: 'demo-seeded-orbit',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: null,
|
||||
syncId: 'demo-seeded-sync',
|
||||
errors: [],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'source_acquired', adapter: 'dbt_descriptions', trigger: 'demo_seeded', fileCount: 6 },
|
||||
{ type: 'source_acquired', adapter: 'looker', trigger: 'demo_seeded', fileCount: 7 },
|
||||
{ type: 'source_acquired', adapter: 'notion', trigger: 'demo_seeded', fileCount: 8 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'revenue-and-contracts', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{
|
||||
type: 'candidate_action',
|
||||
unitKey: 'revenue-and-contracts',
|
||||
target: 'wiki',
|
||||
action: 'created',
|
||||
key: 'knowledge/global/arr-contract-first.md',
|
||||
},
|
||||
{ type: 'work_unit_finished', unitKey: 'revenue-and-contracts', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'demo-seeded', wikiCount: 10, slCount: 6 },
|
||||
{ type: 'provenance_recorded', rowCount: 23 },
|
||||
{ type: 'report_created', runId: 'demo-seeded-orbit', reportPath: 'reports/seeded-demo-report.json' },
|
||||
],
|
||||
plannedWorkUnits: [
|
||||
{ unitKey: 'revenue-and-contracts', rawFiles: ['contracts'], peerFileCount: 1, dependencyCount: 1 },
|
||||
],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
});
|
||||
|
||||
expect(view.title).toBe('KLO memory flow Warehouse + dbt + BI + Docs done');
|
||||
expect(view.columns.find((column) => column.id === 'source')?.counters[0]).toBe('Warehouse, dbt, BI, Docs');
|
||||
expect(view.completionLine).toContain('Saved 16 memories from 29 raw files');
|
||||
});
|
||||
|
||||
it('derives sticky trust issues from failed work units, gates, and provenance mismatch', () => {
|
||||
const input = replayInput();
|
||||
const view = buildMemoryFlowViewModel({
|
||||
...input,
|
||||
events: [
|
||||
...input.events.filter((event) => event.type !== 'provenance_recorded'),
|
||||
{ type: 'provenance_recorded', rowCount: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.trustIssues).toEqual([
|
||||
{
|
||||
id: 'work-unit-failed:revenue',
|
||||
severity: 'failed',
|
||||
title: 'WorkUnit failed',
|
||||
detail: 'revenue failed: validation failed',
|
||||
columnId: 'workUnits',
|
||||
targetLabel: 'revenue',
|
||||
},
|
||||
{
|
||||
id: 'sl-validation-reverted:revenue',
|
||||
severity: 'warning',
|
||||
title: 'SL validation revert',
|
||||
detail: 'revenue reverted after semantic-layer validation failure',
|
||||
columnId: 'gates',
|
||||
targetLabel: 'revenue',
|
||||
},
|
||||
{
|
||||
id: 'reconciliation-conflicts',
|
||||
severity: 'warning',
|
||||
title: 'Reconciliation conflicts',
|
||||
detail: '1 conflict resolved during reconciliation',
|
||||
columnId: 'gates',
|
||||
},
|
||||
{
|
||||
id: 'flagged-fallbacks',
|
||||
severity: 'warning',
|
||||
title: 'Flagged fallbacks',
|
||||
detail: '1 fallback needs review',
|
||||
columnId: 'gates',
|
||||
},
|
||||
{
|
||||
id: 'provenance-mismatch',
|
||||
severity: 'warning',
|
||||
title: 'Provenance mismatch',
|
||||
detail: '2 saved memories but 1 provenance rows recorded',
|
||||
columnId: 'saved',
|
||||
},
|
||||
]);
|
||||
expect(view.columns.find((column) => column.id === 'workUnits')?.chips).toContainEqual({
|
||||
label: 'revenue',
|
||||
status: 'failed',
|
||||
detail: 'validation failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts multiple provenance rows per saved memory', () => {
|
||||
const input = replayInput();
|
||||
const view = buildMemoryFlowViewModel({
|
||||
...input,
|
||||
events: [
|
||||
...input.events.filter((event) => event.type !== 'provenance_recorded'),
|
||||
{ type: 'provenance_recorded', rowCount: 23 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.trustIssues.find((issue) => issue.id === 'provenance-mismatch')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives deterministic mode as a degraded trust issue', () => {
|
||||
const view = buildMemoryFlowViewModel({
|
||||
runId: 'demo-deterministic-scan',
|
||||
connectionId: 'orbit_demo',
|
||||
adapter: 'live-database',
|
||||
status: 'done',
|
||||
sourceDir: 'raw-sources/orbit_demo/live-database/sync-demo',
|
||||
syncId: 'sync-demo',
|
||||
reportPath: 'raw-sources/orbit_demo/live-database/sync-demo/scan-report.json',
|
||||
errors: [],
|
||||
plannedWorkUnits: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'live-database', trigger: 'demo_deterministic', fileCount: 7 },
|
||||
{ type: 'chunks_planned', chunkCount: 7, workUnitCount: 0, evictionCount: 0 },
|
||||
{ type: 'stage_skipped', stage: 'workUnits', reason: 'deterministic mode' },
|
||||
{ type: 'stage_skipped', stage: 'actions', reason: 'requires LLM' },
|
||||
{ type: 'stage_skipped', stage: 'gates', reason: 'requires candidate actions' },
|
||||
{ type: 'stage_skipped', stage: 'saved', reason: 'requires LLM memory synthesis' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.trustIssues).toEqual([
|
||||
{
|
||||
id: 'degraded-mode:workUnits',
|
||||
severity: 'warning',
|
||||
title: 'Degraded mode',
|
||||
detail: 'WORKUNITS skipped: deterministic mode',
|
||||
columnId: 'workUnits',
|
||||
targetLabel: 'skipped',
|
||||
},
|
||||
{
|
||||
id: 'degraded-mode:actions',
|
||||
severity: 'warning',
|
||||
title: 'Degraded mode',
|
||||
detail: 'ACTIONS skipped: requires LLM',
|
||||
columnId: 'actions',
|
||||
targetLabel: 'skipped',
|
||||
},
|
||||
{
|
||||
id: 'degraded-mode:gates',
|
||||
severity: 'warning',
|
||||
title: 'Degraded mode',
|
||||
detail: 'GATES skipped: requires candidate actions',
|
||||
columnId: 'gates',
|
||||
targetLabel: 'skipped',
|
||||
},
|
||||
{
|
||||
id: 'degraded-mode:saved',
|
||||
severity: 'warning',
|
||||
title: 'Degraded mode',
|
||||
detail: 'SAVED skipped: requires LLM memory synthesis',
|
||||
columnId: 'saved',
|
||||
targetLabel: 'skipped',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps local planning-only runs honest about unsaved memory', () => {
|
||||
const view = buildMemoryFlowViewModel({
|
||||
runId: 'local-run-1',
|
||||
connectionId: 'warehouse',
|
||||
adapter: 'fake',
|
||||
status: 'done',
|
||||
sourceDir: '/tmp/source',
|
||||
syncId: 'sync-local',
|
||||
errors: [],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders.json'], peerFileCount: 0, dependencyCount: 0 }],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'fake', trigger: 'manual_resync', fileCount: 1 },
|
||||
{ type: 'scope_detected', fingerprint: null },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-local', rawFileCount: 1 },
|
||||
{ type: 'diff_computed', added: 1, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'report_created', runId: 'local-run-1' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(view.columns.find((column) => column.id === 'actions')?.headline).toBe('0 candidates');
|
||||
expect(view.columns.find((column) => column.id === 'gates')?.headline).toBe('not run');
|
||||
expect(view.columns.find((column) => column.id === 'saved')?.headline).toBe('not saved');
|
||||
expect(view.completionLine).toBe(null);
|
||||
});
|
||||
|
||||
it('surfaces a sanitized source acquisition error when no source event exists', () => {
|
||||
const view = buildMemoryFlowViewModel(
|
||||
baseReplayInput({
|
||||
errors: ['failed to read https://example.com/source?token=abc123 password=hunter2'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(view.activeLine).toBe('active: source failed - failed to read https://[redacted] password=[redacted]');
|
||||
expect(view.selectedTitle).toBe('SOURCE');
|
||||
expect(view.selectedDetails).toContain('Source acquisition failed: failed to read https://[redacted] password=[redacted]');
|
||||
});
|
||||
|
||||
it('surfaces a sanitized planning error after source acquisition but before chunks', () => {
|
||||
const view = buildMemoryFlowViewModel(
|
||||
baseReplayInput({
|
||||
errors: ['adapter detection failed api_key=abc123'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 3 },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-errors', rawFileCount: 3 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(view.activeLine).toBe('active: planning failed - adapter detection failed api_key=[redacted]');
|
||||
const source = view.columns.find((column) => column.id === 'source');
|
||||
expect(source?.details).toContain('Error: adapter detection failed api_key=[redacted]');
|
||||
});
|
||||
|
||||
it('labels failed semantic-layer WorkUnits as reverted in gates details', () => {
|
||||
const view = buildMemoryFlowViewModel(
|
||||
baseReplayInput({
|
||||
status: 'error',
|
||||
errors: ['semantic-layer validation failed for warehouse.orders'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'raw_snapshot_written', syncId: 'sync-errors', rawFileCount: 2 },
|
||||
{ type: 'diff_computed', added: 2, modified: 0, deleted: 0, unchanged: 0 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'orders', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'candidate_action', unitKey: 'orders', target: 'sl', action: 'updated', key: 'warehouse.orders' },
|
||||
{
|
||||
type: 'work_unit_finished',
|
||||
unitKey: 'orders',
|
||||
status: 'failed',
|
||||
reason: 'semantic-layer validation failed for warehouse.orders',
|
||||
},
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'orders', rawFiles: ['orders.yml'], peerFileCount: 0, dependencyCount: 0 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const gates = view.columns.find((column) => column.id === 'gates');
|
||||
expect(gates?.details).toContain('orders reverted: semantic-layer validation failed for warehouse.orders');
|
||||
expect(gates?.details).toContain('Invalid semantic-layer writes were not saved.');
|
||||
});
|
||||
|
||||
it('keeps non-validation WorkUnit failures actionable', () => {
|
||||
const view = buildMemoryFlowViewModel(
|
||||
baseReplayInput({
|
||||
status: 'error',
|
||||
errors: ['agent step budget exhausted'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 1 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_started', unitKey: 'docs', skills: ['knowledge_capture'], stepBudget: 40 },
|
||||
{ type: 'work_unit_finished', unitKey: 'docs', status: 'failed', reason: 'agent step budget exhausted' },
|
||||
],
|
||||
plannedWorkUnits: [{ unitKey: 'docs', rawFiles: ['docs.md'], peerFileCount: 0, dependencyCount: 0 }],
|
||||
}),
|
||||
);
|
||||
|
||||
const gates = view.columns.find((column) => column.id === 'gates');
|
||||
expect(gates?.details).toContain('docs failed: agent step budget exhausted');
|
||||
});
|
||||
|
||||
it('shows whether durable memory landed before a post-save failure', () => {
|
||||
const view = buildMemoryFlowViewModel(
|
||||
baseReplayInput({
|
||||
status: 'error',
|
||||
errors: ['index refresh failed token=abc123'],
|
||||
events: [
|
||||
{ type: 'source_acquired', adapter: 'metricflow', trigger: 'manual_resync', fileCount: 2 },
|
||||
{ type: 'chunks_planned', chunkCount: 1, workUnitCount: 1, evictionCount: 0 },
|
||||
{ type: 'work_unit_finished', unitKey: 'orders', status: 'success' },
|
||||
{ type: 'reconciliation_finished', conflictCount: 0, fallbackCount: 0 },
|
||||
{ type: 'saved', commitSha: 'abc123456789', wikiCount: 1, slCount: 1 }, // pragma: allowlist secret
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const saved = view.columns.find((column) => column.id === 'saved');
|
||||
expect(saved?.details).toContain('Durable memory landed before failure.');
|
||||
expect(saved?.details).toContain('Post-save error: index refresh failed token=[redacted]');
|
||||
expect(view.activeLine).toBe('active: save failed - index refresh failed token=[redacted]');
|
||||
});
|
||||
});
|
||||
523
packages/context/src/ingest/memory-flow/view-model.ts
Normal file
523
packages/context/src/ingest/memory-flow/view-model.ts
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import type {
|
||||
MemoryFlowChip,
|
||||
MemoryFlowColumnId,
|
||||
MemoryFlowColumnView,
|
||||
MemoryFlowDisplayStatus,
|
||||
MemoryFlowEvent,
|
||||
MemoryFlowReplayInput,
|
||||
MemoryFlowTrustIssue,
|
||||
MemoryFlowViewModel,
|
||||
} from './types.js';
|
||||
import { sanitizeMemoryFlowError } from './live-buffer.js';
|
||||
|
||||
function latest<T extends MemoryFlowEvent['type']>(
|
||||
events: MemoryFlowEvent[],
|
||||
type: T,
|
||||
): Extract<MemoryFlowEvent, { type: T }> | undefined {
|
||||
return events.filter((event): event is Extract<MemoryFlowEvent, { type: T }> => event.type === type).at(-1);
|
||||
}
|
||||
|
||||
function eventsOf<T extends MemoryFlowEvent['type']>(
|
||||
events: MemoryFlowEvent[],
|
||||
type: T,
|
||||
): Array<Extract<MemoryFlowEvent, { type: T }>> {
|
||||
return events.filter((event): event is Extract<MemoryFlowEvent, { type: T }> => event.type === type);
|
||||
}
|
||||
|
||||
function skippedStage(
|
||||
input: MemoryFlowReplayInput,
|
||||
stage: Extract<MemoryFlowEvent, { type: 'stage_skipped' }>['stage'],
|
||||
): Extract<MemoryFlowEvent, { type: 'stage_skipped' }> | undefined {
|
||||
return eventsOf(input.events, 'stage_skipped').find((event) => event.stage === stage);
|
||||
}
|
||||
|
||||
function formatDiff(diff: Extract<MemoryFlowEvent, { type: 'diff_computed' }> | undefined): string {
|
||||
if (!diff) return '+0 ~0 -0 =0';
|
||||
return `+${diff.added} ~${diff.modified} -${diff.deleted} =${diff.unchanged}`;
|
||||
}
|
||||
|
||||
function countCandidateActions(events: MemoryFlowEvent[], target: 'wiki' | 'sl'): number {
|
||||
return eventsOf(events, 'candidate_action').filter((event) => event.target === target).length;
|
||||
}
|
||||
|
||||
function columnStatus(input: {
|
||||
hasFailures?: boolean;
|
||||
hasWarnings?: boolean;
|
||||
hasActivity?: boolean;
|
||||
complete?: boolean;
|
||||
}): MemoryFlowDisplayStatus {
|
||||
if (input.hasFailures) return 'failed';
|
||||
if (input.hasWarnings) return 'warning';
|
||||
if (input.hasActivity) return 'active';
|
||||
if (input.complete) return 'complete';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
function firstChips(labels: string[], status: MemoryFlowDisplayStatus): Array<{ label: string; status: MemoryFlowDisplayStatus }> {
|
||||
return labels.slice(0, 2).map((label) => ({ label, status }));
|
||||
}
|
||||
|
||||
function safeErrors(input: MemoryFlowReplayInput): string[] {
|
||||
return input.errors.map((error) => sanitizeMemoryFlowError(error)).filter((error) => error.length > 0);
|
||||
}
|
||||
|
||||
function latestSafeError(input: MemoryFlowReplayInput): string | null {
|
||||
return safeErrors(input)[0] ?? null;
|
||||
}
|
||||
|
||||
function failureStage(input: MemoryFlowReplayInput): 'source' | 'planning' | 'work_unit' | 'save' | 'run' {
|
||||
const hasSource = !!latest(input.events, 'source_acquired');
|
||||
const hasChunks = !!latest(input.events, 'chunks_planned');
|
||||
const hasFailedWorkUnit = eventsOf(input.events, 'work_unit_finished').some((event) => event.status === 'failed');
|
||||
const hasSaved = !!latest(input.events, 'saved');
|
||||
|
||||
if (!hasSource) return 'source';
|
||||
if (!hasChunks) return 'planning';
|
||||
if (hasFailedWorkUnit) return 'work_unit';
|
||||
if (hasSaved) return 'save';
|
||||
return 'run';
|
||||
}
|
||||
|
||||
function activeLine(input: MemoryFlowReplayInput): string {
|
||||
if (input.status !== 'error') {
|
||||
return input.status === 'running' ? 'active: running' : 'active: complete';
|
||||
}
|
||||
|
||||
const error = latestSafeError(input);
|
||||
if (!error) return 'active: error';
|
||||
|
||||
const stage = failureStage(input);
|
||||
return `active: ${stage.replace('_', ' ')} failed - ${error}`;
|
||||
}
|
||||
|
||||
function errorDetails(input: MemoryFlowReplayInput): string[] {
|
||||
const errors = safeErrors(input);
|
||||
if (errors.length === 0) return [];
|
||||
|
||||
const [first, ...rest] = errors;
|
||||
const stage = failureStage(input);
|
||||
const label =
|
||||
stage === 'source'
|
||||
? 'Source acquisition failed'
|
||||
: stage === 'planning'
|
||||
? 'Error'
|
||||
: stage === 'save'
|
||||
? 'Post-save error'
|
||||
: 'Error';
|
||||
|
||||
return [`${label}: ${first}`, ...rest.map((error) => `Error: ${error}`)];
|
||||
}
|
||||
|
||||
function isValidationFailure(reason: string | undefined): boolean {
|
||||
return /semantic-layer|validation|invalid/i.test(reason ?? '');
|
||||
}
|
||||
|
||||
function failedWorkUnitDetails(failed: Array<Extract<MemoryFlowEvent, { type: 'work_unit_finished' }>>): string[] {
|
||||
const details = failed.map((event) => {
|
||||
const reason = event.reason ?? 'failed';
|
||||
const label = isValidationFailure(reason) ? 'reverted' : 'failed';
|
||||
return `${event.unitKey} ${label}: ${sanitizeMemoryFlowError(reason)}`;
|
||||
});
|
||||
|
||||
if (failed.some((event) => isValidationFailure(event.reason))) {
|
||||
details.push('Invalid semantic-layer writes were not saved.');
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function columnTitle(columnId: MemoryFlowColumnId): string {
|
||||
if (columnId === 'workUnits') return 'WORKUNITS';
|
||||
return columnId.toUpperCase();
|
||||
}
|
||||
|
||||
function plural(value: number, singular: string, pluralLabel = `${singular}s`): string {
|
||||
return `${value} ${value === 1 ? singular : pluralLabel}`;
|
||||
}
|
||||
|
||||
function finishedWorkUnitByKey(
|
||||
input: MemoryFlowReplayInput,
|
||||
): Map<string, Extract<MemoryFlowEvent, { type: 'work_unit_finished' }>> {
|
||||
return new Map(eventsOf(input.events, 'work_unit_finished').map((event) => [event.unitKey, event]));
|
||||
}
|
||||
|
||||
function workUnitChips(input: MemoryFlowReplayInput): MemoryFlowChip[] {
|
||||
const finishedByKey = finishedWorkUnitByKey(input);
|
||||
return input.plannedWorkUnits.slice(0, 8).map((workUnit) => {
|
||||
const finished = finishedByKey.get(workUnit.unitKey);
|
||||
if (finished?.status === 'failed') {
|
||||
return {
|
||||
label: workUnit.unitKey,
|
||||
status: 'failed',
|
||||
detail: sanitizeMemoryFlowError(finished.reason ?? 'failed'),
|
||||
};
|
||||
}
|
||||
return { label: workUnit.unitKey, status: finished ? 'complete' : 'active' };
|
||||
});
|
||||
}
|
||||
|
||||
function actionChips(
|
||||
input: MemoryFlowReplayInput,
|
||||
events: Array<Extract<MemoryFlowEvent, { type: 'candidate_action' }>>,
|
||||
): MemoryFlowChip[] {
|
||||
if (input.details.actions.length > 0) {
|
||||
return input.details.actions.slice(0, 8).map((action) => ({
|
||||
label: action.key,
|
||||
status: action.status === 'failed' ? 'failed' : 'complete',
|
||||
detail: action.status === 'failed' ? action.summary : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return events.slice(0, 8).map((action) => ({ label: action.key, status: 'complete' }));
|
||||
}
|
||||
|
||||
function buildMemoryFlowTrustIssues(input: MemoryFlowReplayInput): MemoryFlowTrustIssue[] {
|
||||
const issues: MemoryFlowTrustIssue[] = [];
|
||||
const failed = eventsOf(input.events, 'work_unit_finished').filter((event) => event.status === 'failed');
|
||||
const reconciliation = latest(input.events, 'reconciliation_finished');
|
||||
const saved = latest(input.events, 'saved');
|
||||
const provenance = latest(input.events, 'provenance_recorded');
|
||||
|
||||
for (const event of failed) {
|
||||
const reason = sanitizeMemoryFlowError(event.reason ?? 'failed');
|
||||
issues.push({
|
||||
id: `work-unit-failed:${event.unitKey}`,
|
||||
severity: 'failed',
|
||||
title: 'WorkUnit failed',
|
||||
detail: `${event.unitKey} failed: ${reason}`,
|
||||
columnId: 'workUnits',
|
||||
targetLabel: event.unitKey,
|
||||
});
|
||||
|
||||
if (isValidationFailure(event.reason)) {
|
||||
issues.push({
|
||||
id: `sl-validation-reverted:${event.unitKey}`,
|
||||
severity: 'warning',
|
||||
title: 'SL validation revert',
|
||||
detail: `${event.unitKey} reverted after semantic-layer validation failure`,
|
||||
columnId: 'gates',
|
||||
targetLabel: event.unitKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((reconciliation?.conflictCount ?? 0) > 0) {
|
||||
issues.push({
|
||||
id: 'reconciliation-conflicts',
|
||||
severity: 'warning',
|
||||
title: 'Reconciliation conflicts',
|
||||
detail: `${plural(reconciliation?.conflictCount ?? 0, 'conflict')} resolved during reconciliation`,
|
||||
columnId: 'gates',
|
||||
});
|
||||
}
|
||||
|
||||
if ((reconciliation?.fallbackCount ?? 0) > 0) {
|
||||
issues.push({
|
||||
id: 'flagged-fallbacks',
|
||||
severity: 'warning',
|
||||
title: 'Flagged fallbacks',
|
||||
detail: `${plural(reconciliation?.fallbackCount ?? 0, 'fallback')} needs review`,
|
||||
columnId: 'gates',
|
||||
});
|
||||
}
|
||||
|
||||
const savedCount = (saved?.wikiCount ?? 0) + (saved?.slCount ?? 0);
|
||||
if (savedCount > 0 && provenance && provenance.rowCount < savedCount) {
|
||||
issues.push({
|
||||
id: 'provenance-mismatch',
|
||||
severity: 'warning',
|
||||
title: 'Provenance mismatch',
|
||||
detail: `${savedCount} saved memories but ${provenance.rowCount} provenance rows recorded`,
|
||||
columnId: 'saved',
|
||||
});
|
||||
}
|
||||
|
||||
for (const skipped of eventsOf(input.events, 'stage_skipped')) {
|
||||
issues.push({
|
||||
id: `degraded-mode:${skipped.stage}`,
|
||||
severity: 'warning',
|
||||
title: 'Degraded mode',
|
||||
detail: `${columnTitle(skipped.stage)} skipped: ${skipped.reason}`,
|
||||
columnId: skipped.stage,
|
||||
targetLabel: 'skipped',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, error] of safeErrors(input).entries()) {
|
||||
issues.push({
|
||||
id: `run-error:${index}`,
|
||||
severity: 'failed',
|
||||
title: 'Run error',
|
||||
detail: error,
|
||||
columnId: failureStage(input) === 'source' ? 'source' : 'gates',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function humanizeAdapter(adapter: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'live-database': 'Warehouse',
|
||||
'live_database': 'Warehouse',
|
||||
'dbt_descriptions': 'dbt',
|
||||
'looker': 'BI',
|
||||
'lookml': 'BI',
|
||||
'notion': 'Docs',
|
||||
'metabase': 'BI',
|
||||
'metricflow': 'dbt',
|
||||
'historic_sql': 'SQL',
|
||||
};
|
||||
return labels[adapter] ?? adapter;
|
||||
}
|
||||
|
||||
function sourceColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const sources = eventsOf(input.events, 'source_acquired');
|
||||
const source = sources.at(-1);
|
||||
const snapshot = latest(input.events, 'raw_snapshot_written');
|
||||
const scope = latest(input.events, 'scope_detected');
|
||||
const totalFiles = sources.reduce((sum, s) => sum + s.fileCount, 0);
|
||||
const adapterLabels = sources.length > 1
|
||||
? [...new Set(sources.map((s) => humanizeAdapter(s.adapter)))]
|
||||
: [input.adapter, input.connectionId];
|
||||
return {
|
||||
id: 'source',
|
||||
title: 'SOURCE',
|
||||
status: columnStatus({ complete: !!source }),
|
||||
headline: `${totalFiles} raw files`,
|
||||
counters: sources.length > 1
|
||||
? [adapterLabels.join(', '), `sync ${snapshot?.syncId ?? input.syncId}`]
|
||||
: [`sync ${snapshot?.syncId ?? input.syncId}`, scope?.fingerprint ? `scope ${scope.fingerprint}` : 'scope none'],
|
||||
chips: adapterLabels.map((label) => ({ label, status: 'complete' as MemoryFlowDisplayStatus })),
|
||||
details: [
|
||||
`Trigger: ${source?.trigger ?? 'unknown'}`,
|
||||
...(sources.length > 1
|
||||
? sources.map((s) => `${humanizeAdapter(s.adapter)}: ${s.fileCount} files`)
|
||||
: [`Adapter: ${input.adapter}`]),
|
||||
`Connection: ${input.connectionId}`,
|
||||
`Source: ${input.sourceDir ?? 'stored report'}`,
|
||||
...errorDetails(input),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function chunksColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const chunks = latest(input.events, 'chunks_planned');
|
||||
const diff = latest(input.events, 'diff_computed');
|
||||
return {
|
||||
id: 'chunks',
|
||||
title: 'CHUNKS',
|
||||
status: columnStatus({ hasWarnings: (chunks?.evictionCount ?? 0) > 0, complete: !!chunks }),
|
||||
headline: `${chunks?.chunkCount ?? 0} chunks`,
|
||||
counters: [formatDiff(diff), `${chunks?.evictionCount ?? 0} deletions`],
|
||||
chips: firstChips(input.plannedWorkUnits.map((workUnit) => workUnit.unitKey), 'complete'),
|
||||
details: [
|
||||
`Work units planned: ${chunks?.workUnitCount ?? 0}`,
|
||||
`Eviction candidates: ${chunks?.evictionCount ?? 0}`,
|
||||
`Diff: ${formatDiff(diff)}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function workUnitsColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const finished = eventsOf(input.events, 'work_unit_finished');
|
||||
const failed = finished.filter((event) => event.status === 'failed');
|
||||
const succeeded = finished.filter((event) => event.status === 'success');
|
||||
const active = eventsOf(input.events, 'work_unit_started').filter(
|
||||
(started) => !finished.some((event) => event.unitKey === started.unitKey),
|
||||
);
|
||||
const total = input.plannedWorkUnits.length || latest(input.events, 'chunks_planned')?.workUnitCount || 0;
|
||||
const skipped = skippedStage(input, 'workUnits');
|
||||
if (skipped) {
|
||||
return {
|
||||
id: 'workUnits',
|
||||
title: 'WORKUNITS',
|
||||
status: 'warning',
|
||||
headline: 'skipped',
|
||||
counters: ['0 done', '0 failed', '0 active'],
|
||||
chips: [{ label: 'skipped', status: 'warning', detail: skipped.reason }],
|
||||
details: [`Skipped: ${skipped.reason}`],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'workUnits',
|
||||
title: 'WORKUNITS',
|
||||
status: columnStatus({ hasFailures: failed.length > 0, hasActivity: active.length > 0, complete: total > 0 }),
|
||||
headline: `${total} WUs`,
|
||||
counters: [`${succeeded.length} done`, `${failed.length} failed`, `${active.length} active`],
|
||||
chips: workUnitChips(input),
|
||||
details: input.plannedWorkUnits.map(
|
||||
(workUnit) =>
|
||||
`${workUnit.unitKey}: ${workUnit.rawFiles.length} raw, ${workUnit.peerFileCount} peers, ${workUnit.dependencyCount} deps`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function actionsColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const actions = eventsOf(input.events, 'candidate_action');
|
||||
const wikiCount = countCandidateActions(input.events, 'wiki');
|
||||
const slCount = countCandidateActions(input.events, 'sl');
|
||||
const skipped = skippedStage(input, 'actions');
|
||||
if (skipped) {
|
||||
return {
|
||||
id: 'actions',
|
||||
title: 'ACTIONS',
|
||||
status: 'warning',
|
||||
headline: 'skipped',
|
||||
counters: ['0 wiki', '0 SL'],
|
||||
chips: [{ label: 'skipped', status: 'warning', detail: skipped.reason }],
|
||||
details: [`Skipped: ${skipped.reason}`],
|
||||
};
|
||||
}
|
||||
const details = input.details.actions.length
|
||||
? input.details.actions.map(
|
||||
(action) => `${action.unitKey} ${action.target} ${action.action} ${action.key}: ${action.summary}`,
|
||||
)
|
||||
: actions.map((action) => `${action.target} ${action.action}: ${action.key}`);
|
||||
return {
|
||||
id: 'actions',
|
||||
title: 'ACTIONS',
|
||||
status: columnStatus({ complete: actions.length > 0 }),
|
||||
headline: `${actions.length} candidates`,
|
||||
counters: [`${wikiCount} wiki`, `${slCount} SL`],
|
||||
chips: actionChips(input, actions),
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function gatesColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const reconciliation = latest(input.events, 'reconciliation_finished');
|
||||
const failed = eventsOf(input.events, 'work_unit_finished').filter((event) => event.status === 'failed');
|
||||
const headline = reconciliation
|
||||
? `${reconciliation.conflictCount} conflict, ${reconciliation.fallbackCount} fallback`
|
||||
: 'not run';
|
||||
const skipped = skippedStage(input, 'gates');
|
||||
if (skipped) {
|
||||
return {
|
||||
id: 'gates',
|
||||
title: 'GATES',
|
||||
status: 'warning',
|
||||
headline: 'skipped',
|
||||
counters: ['0 failed', '0 flagged'],
|
||||
chips: [{ label: 'skipped', status: 'warning', detail: skipped.reason }],
|
||||
details: [`Skipped: ${skipped.reason}`],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'gates',
|
||||
title: 'GATES',
|
||||
status: columnStatus({
|
||||
hasFailures: failed.length > 0,
|
||||
hasWarnings: (reconciliation?.conflictCount ?? 0) > 0 || (reconciliation?.fallbackCount ?? 0) > 0,
|
||||
complete: !!reconciliation,
|
||||
}),
|
||||
headline,
|
||||
counters: [`${failed.length} failed`, `${reconciliation?.fallbackCount ?? 0} flagged`],
|
||||
chips: firstChips(failed.map((event) => event.unitKey), 'failed'),
|
||||
details: [
|
||||
`Reconciliation: ${headline}`,
|
||||
`Failed work units: ${failed.length}`,
|
||||
`Conflicts resolved: ${reconciliation?.conflictCount ?? 0}`,
|
||||
`Flagged fallbacks: ${reconciliation?.fallbackCount ?? 0}`,
|
||||
...failedWorkUnitDetails(failed),
|
||||
...errorDetails(input),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function savedColumn(input: MemoryFlowReplayInput): MemoryFlowColumnView {
|
||||
const saved = latest(input.events, 'saved');
|
||||
const provenance = latest(input.events, 'provenance_recorded');
|
||||
const report = latest(input.events, 'report_created');
|
||||
const memoryCount = (saved?.wikiCount ?? 0) + (saved?.slCount ?? 0);
|
||||
const chipLabels = [saved?.commitSha ? saved.commitSha.slice(0, 8) : '', report?.reportPath ?? ''].filter(
|
||||
(label): label is string => label.length > 0,
|
||||
);
|
||||
const skipped = skippedStage(input, 'saved');
|
||||
if (skipped) {
|
||||
return {
|
||||
id: 'saved',
|
||||
title: 'SAVED',
|
||||
status: 'warning',
|
||||
headline: '0 memories',
|
||||
counters: ['0 wiki', '0 SL', '0 provenance'],
|
||||
chips: [{ label: 'skipped', status: 'warning', detail: skipped.reason }],
|
||||
details: [
|
||||
`Skipped: ${skipped.reason}`,
|
||||
`Run: ${input.runId}`,
|
||||
`Report: ${report?.reportPath ?? input.reportPath ?? 'none'}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'saved',
|
||||
title: 'SAVED',
|
||||
status: columnStatus({ complete: memoryCount > 0 }),
|
||||
headline: memoryCount > 0 ? `${memoryCount} memories` : 'not saved',
|
||||
counters: [`${saved?.wikiCount ?? 0} wiki`, `${saved?.slCount ?? 0} SL`, `${provenance?.rowCount ?? 0} provenance`],
|
||||
chips: firstChips(chipLabels, 'complete'),
|
||||
details: [
|
||||
`Commit: ${saved?.commitSha ? saved.commitSha.slice(0, 8) : 'none'}`,
|
||||
`Run: ${input.runId}`,
|
||||
`Report: ${report?.reportPath ?? input.reportPath ?? 'none'}`,
|
||||
`Provenance rows: ${provenance?.rowCount ?? 0}`,
|
||||
...(input.status === 'error' && saved ? ['Durable memory landed before failure.'] : []),
|
||||
...(input.status === 'error' && saved ? errorDetails(input) : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function completionLine(input: MemoryFlowReplayInput): string | null {
|
||||
const sources = eventsOf(input.events, 'source_acquired');
|
||||
const saved = latest(input.events, 'saved');
|
||||
const report = latest(input.events, 'report_created');
|
||||
if (sources.length === 0 || !saved || saved.wikiCount + saved.slCount === 0) {
|
||||
return null;
|
||||
}
|
||||
const totalFiles = sources.reduce((sum, event) => sum + event.fileCount, 0);
|
||||
const commit = saved.commitSha ? saved.commitSha.slice(0, 8) : 'none';
|
||||
return `Saved ${saved.wikiCount + saved.slCount} memories from ${totalFiles} raw files: ${saved.wikiCount} wiki pages, ${saved.slCount} SL updates. Commit: ${commit} Run: ${input.runId} Report: ${report?.reportPath ?? input.reportPath ?? 'none'}`;
|
||||
}
|
||||
|
||||
export function buildMemoryFlowViewModel(input: MemoryFlowReplayInput): MemoryFlowViewModel {
|
||||
const columns = [
|
||||
sourceColumn(input),
|
||||
chunksColumn(input),
|
||||
workUnitsColumn(input),
|
||||
actionsColumn(input),
|
||||
gatesColumn(input),
|
||||
savedColumn(input),
|
||||
];
|
||||
const plannedWorkUnitsColumn = columns.find((column) => column.id === 'workUnits');
|
||||
const errorColumn =
|
||||
input.status === 'error'
|
||||
? columns.find((column) => column.id === (failureStage(input) === 'source' ? 'source' : 'gates'))
|
||||
: undefined;
|
||||
const warningColumn = columns.find((column) => column.status === 'warning');
|
||||
const firstExpandableColumn =
|
||||
errorColumn ??
|
||||
warningColumn ??
|
||||
(input.plannedWorkUnits.length > 0 && !latest(input.events, 'saved') && plannedWorkUnitsColumn
|
||||
? plannedWorkUnitsColumn
|
||||
: (columns.find((column) => column.details.length > 0) ?? columns[0]));
|
||||
const trustIssues = buildMemoryFlowTrustIssues(input);
|
||||
|
||||
const sources = eventsOf(input.events, 'source_acquired');
|
||||
const titleSources = sources.length > 1
|
||||
? [...new Set(sources.map((s) => humanizeAdapter(s.adapter)))].join(' + ')
|
||||
: `${input.connectionId}/${input.adapter}`;
|
||||
|
||||
return {
|
||||
title: `KLO memory flow ${titleSources} ${input.status}`,
|
||||
subtitle: `Run ${input.runId} Sync ${input.syncId}`,
|
||||
status: input.status,
|
||||
activeLine: activeLine(input),
|
||||
columns,
|
||||
trustIssues,
|
||||
selectedTitle: firstExpandableColumn.title,
|
||||
selectedDetails: firstExpandableColumn.details,
|
||||
completionLine: completionLine(input),
|
||||
details: input.details,
|
||||
};
|
||||
}
|
||||
70
packages/context/src/ingest/memory-flow/visuals.test.ts
Normal file
70
packages/context/src/ingest/memory-flow/visuals.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildMemoryFlowVisualModel,
|
||||
memoryFlowStatusBadge,
|
||||
renderMemoryFlowConnectorLine,
|
||||
} from './visuals.js';
|
||||
import type { MemoryFlowViewModel } from './types.js';
|
||||
|
||||
function viewWithStatuses(statuses: Array<'waiting' | 'active' | 'complete' | 'warning' | 'failed'>): MemoryFlowViewModel {
|
||||
const titles = ['SOURCE', 'CHUNKS', 'WORKUNITS', 'ACTIONS', 'GATES', 'SAVED'];
|
||||
const ids = ['source', 'chunks', 'workUnits', 'actions', 'gates', 'saved'] as const;
|
||||
|
||||
return {
|
||||
title: 'KLO memory flow warehouse/metricflow running',
|
||||
subtitle: 'Run run-1 Sync sync-1',
|
||||
status: 'running',
|
||||
activeLine: 'active: WorkUnit orders',
|
||||
selectedTitle: 'WORKUNITS',
|
||||
selectedDetails: ['orders: 1 raw, 0 peers, 1 deps'],
|
||||
completionLine: null,
|
||||
trustIssues: [],
|
||||
details: { actions: [], provenance: [], transcripts: [] },
|
||||
columns: statuses.map((status, index) => ({
|
||||
id: ids[index],
|
||||
title: titles[index],
|
||||
status,
|
||||
headline: `${titles[index].toLowerCase()} headline`,
|
||||
counters: [],
|
||||
chips: [],
|
||||
details: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('memory-flow visual helpers', () => {
|
||||
it('uses ASCII badges with text meaning for every status', () => {
|
||||
expect(memoryFlowStatusBadge('waiting')).toEqual({ label: '..', text: 'waiting' });
|
||||
expect(memoryFlowStatusBadge('active')).toEqual({ label: '>>', text: 'active' });
|
||||
expect(memoryFlowStatusBadge('complete')).toEqual({ label: 'OK', text: 'complete' });
|
||||
expect(memoryFlowStatusBadge('warning')).toEqual({ label: '!!', text: 'warning' });
|
||||
expect(memoryFlowStatusBadge('failed')).toEqual({ label: 'XX', text: 'failed' });
|
||||
});
|
||||
|
||||
it('renders a no-color connector line with status badges and six columns', () => {
|
||||
const view = viewWithStatuses(['complete', 'complete', 'active', 'waiting', 'waiting', 'waiting']);
|
||||
|
||||
expect(renderMemoryFlowConnectorLine(view)).toBe(
|
||||
'OK SOURCE -> OK CHUNKS -> >> WORKUNITS -> .. ACTIONS -> .. GATES -> .. SAVED',
|
||||
);
|
||||
});
|
||||
|
||||
it('moves the pulse to the active column, then warnings, failures, and the last completed column', () => {
|
||||
expect(
|
||||
buildMemoryFlowVisualModel(viewWithStatuses(['complete', 'complete', 'active', 'waiting', 'waiting', 'waiting']))
|
||||
.pulseColumnId,
|
||||
).toBe('workUnits');
|
||||
expect(
|
||||
buildMemoryFlowVisualModel(viewWithStatuses(['complete', 'warning', 'complete', 'waiting', 'waiting', 'waiting']))
|
||||
.pulseColumnId,
|
||||
).toBe('chunks');
|
||||
expect(
|
||||
buildMemoryFlowVisualModel(viewWithStatuses(['complete', 'complete', 'failed', 'waiting', 'waiting', 'waiting']))
|
||||
.pulseColumnId,
|
||||
).toBe('workUnits');
|
||||
expect(
|
||||
buildMemoryFlowVisualModel(viewWithStatuses(['complete', 'complete', 'complete', 'complete', 'waiting', 'waiting']))
|
||||
.pulseColumnId,
|
||||
).toBe('actions');
|
||||
});
|
||||
});
|
||||
78
packages/context/src/ingest/memory-flow/visuals.ts
Normal file
78
packages/context/src/ingest/memory-flow/visuals.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type {
|
||||
MemoryFlowColumnId,
|
||||
MemoryFlowColumnView,
|
||||
MemoryFlowDisplayStatus,
|
||||
MemoryFlowViewModel,
|
||||
} from './types.js';
|
||||
|
||||
export interface MemoryFlowStatusBadge {
|
||||
label: '..' | '>>' | 'OK' | '!!' | 'XX';
|
||||
text: 'waiting' | 'active' | 'complete' | 'warning' | 'failed';
|
||||
}
|
||||
|
||||
export interface MemoryFlowVisualColumn {
|
||||
id: MemoryFlowColumnId;
|
||||
title: string;
|
||||
status: MemoryFlowDisplayStatus;
|
||||
badge: MemoryFlowStatusBadge;
|
||||
pulse: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryFlowVisualModel {
|
||||
columns: MemoryFlowVisualColumn[];
|
||||
connectorLine: string;
|
||||
pulseColumnId: MemoryFlowColumnId;
|
||||
}
|
||||
|
||||
export function memoryFlowStatusBadge(status: MemoryFlowDisplayStatus): MemoryFlowStatusBadge {
|
||||
if (status === 'active') return { label: '>>', text: 'active' };
|
||||
if (status === 'complete') return { label: 'OK', text: 'complete' };
|
||||
if (status === 'warning') return { label: '!!', text: 'warning' };
|
||||
if (status === 'failed') return { label: 'XX', text: 'failed' };
|
||||
return { label: '..', text: 'waiting' };
|
||||
}
|
||||
|
||||
function firstColumnWithStatus(
|
||||
columns: MemoryFlowColumnView[],
|
||||
status: MemoryFlowDisplayStatus,
|
||||
): MemoryFlowColumnView | undefined {
|
||||
return columns.find((column) => column.status === status);
|
||||
}
|
||||
|
||||
function lastCompletedColumn(columns: MemoryFlowColumnView[]): MemoryFlowColumnView {
|
||||
return [...columns].reverse().find((column) => column.status === 'complete') ?? columns[0];
|
||||
}
|
||||
|
||||
function selectPulseColumn(columns: MemoryFlowColumnView[]): MemoryFlowColumnView {
|
||||
return (
|
||||
firstColumnWithStatus(columns, 'active') ??
|
||||
firstColumnWithStatus(columns, 'warning') ??
|
||||
firstColumnWithStatus(columns, 'failed') ??
|
||||
lastCompletedColumn(columns)
|
||||
);
|
||||
}
|
||||
|
||||
function renderColumn(column: MemoryFlowVisualColumn): string {
|
||||
return `${column.badge.label} ${column.title}`;
|
||||
}
|
||||
|
||||
export function buildMemoryFlowVisualModel(view: MemoryFlowViewModel): MemoryFlowVisualModel {
|
||||
const pulseColumn = selectPulseColumn(view.columns);
|
||||
const columns = view.columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
status: column.status,
|
||||
badge: memoryFlowStatusBadge(column.status),
|
||||
pulse: column.id === pulseColumn.id,
|
||||
}));
|
||||
|
||||
return {
|
||||
columns,
|
||||
connectorLine: columns.map(renderColumn).join(' -> '),
|
||||
pulseColumnId: pulseColumn.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMemoryFlowConnectorLine(view: MemoryFlowViewModel): string {
|
||||
return buildMemoryFlowVisualModel(view).connectorLine;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue