@hassangameryt/freeflow 0.7.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +28 -23
- package/capabilities/cognitive-routing/SKILL.md +94 -311
- package/command-surface.json +7 -1
- package/gemini-extension.json +1 -1
- package/package.json +4 -4
- package/pi-extension/dist/cognitive-routing-v2/budget.js +42 -0
- package/pi-extension/dist/cognitive-routing-v2/config.js +120 -0
- package/pi-extension/dist/cognitive-routing-v2/projection.js +321 -0
- package/pi-extension/dist/cognitive-routing-v2/render.js +231 -0
- package/pi-extension/dist/cognitive-routing-v2/runtime.js +1585 -0
- package/pi-extension/dist/cognitive-routing-v2/schemas.js +107 -0
- package/pi-extension/dist/cognitive-routing-v2/state.js +612 -0
- package/pi-extension/dist/cognitive-routing-v2/tools.js +91 -0
- package/pi-extension/dist/cognitive-routing-v2/types.js +44 -0
- package/pi-extension/dist/freeflow-context/tool.js +5 -0
- package/pi-extension/dist/index.js +279 -624
- package/pi-extension/dist/runtime/runtime-context.js +63 -13
- package/pi-extension/dist/session-sources/events.js +195 -0
- package/pi-extension/dist/session-sources/read-only-session.js +148 -0
- package/pi-extension/dist/session-sources/sources.js +233 -0
- package/pi-extension/dist/settings/settings-ui.js +44 -117
- package/pi-extension/src/cognitive-routing-v2/budget.ts +42 -0
- package/pi-extension/src/cognitive-routing-v2/config.ts +143 -0
- package/pi-extension/src/cognitive-routing-v2/projection.ts +365 -0
- package/pi-extension/src/cognitive-routing-v2/render.ts +256 -0
- package/pi-extension/src/cognitive-routing-v2/runtime.ts +1634 -0
- package/pi-extension/src/cognitive-routing-v2/schemas.ts +109 -0
- package/pi-extension/src/cognitive-routing-v2/state.ts +622 -0
- package/pi-extension/src/cognitive-routing-v2/tools.ts +93 -0
- package/pi-extension/src/cognitive-routing-v2/types.ts +222 -0
- package/pi-extension/src/freeflow-context/tool.ts +5 -0
- package/pi-extension/src/index.ts +285 -674
- package/pi-extension/src/runtime/runtime-context.ts +75 -14
- package/pi-extension/src/session-sources/events.ts +208 -0
- package/pi-extension/src/session-sources/read-only-session.ts +170 -0
- package/pi-extension/src/session-sources/sources.ts +263 -0
- package/pi-extension/src/settings/settings-ui.ts +54 -131
- package/pi-extension/tests/cognitive-routing-v2/communication.test.js +484 -0
- package/pi-extension/tests/cognitive-routing-v2/compaction-input.test.js +183 -0
- package/pi-extension/tests/cognitive-routing-v2/native.test.js +441 -0
- package/pi-extension/tests/cognitive-routing-v2/performance.test.js +87 -0
- package/pi-extension/tests/cognitive-routing-v2/persistence.test.js +245 -0
- package/pi-extension/tests/cognitive-routing-v2/projection.test.js +241 -0
- package/pi-extension/tests/cognitive-routing-v2/recovery.test.js +236 -0
- package/pi-extension/tests/cognitive-routing-v2/render.test.js +230 -0
- package/pi-extension/tests/cognitive-routing-v2/runtime.test.js +153 -0
- package/pi-extension/tests/cognitive-routing-v2/stabilization.test.js +521 -0
- package/pi-extension/tests/cognitive-routing-v2/state.test.js +275 -0
- package/pi-extension/tests/context-virtualization/extension.test.js +16 -0
- package/pi-extension/tests/fixtures/routing-native.js +188 -0
- package/pi-extension/tests/integration/mode-free-core.test.js +3 -2
- package/pi-extension/tests/integration/pi-extension.test.js +154 -37
- package/pi-extension/tests/integration/prompt-architecture.test.js +87 -6
- package/pi-extension/tests/integration/unaffected-regressions.test.js +77 -10
- package/plugin.json +1 -1
- package/runtime/prompts/cognitive-routing.md +4 -5
- package/runtime/prompts/core.md +51 -45
- package/runtime/prompts/interaction-contract.md +5 -5
- package/skills/action-selection/SKILL.md +57 -97
- package/skills/design-for-depth/SKILL.md +58 -193
- package/skills/design-for-depth/references/implementation-decisions.md +76 -0
- package/skills/diagnose-failure/SKILL.md +64 -196
- package/skills/discuss/SKILL.md +64 -123
- package/skills/discuss/references/discussion-continuity.md +7 -3
- package/skills/execute-work/SKILL.md +69 -88
- package/skills/execute-work/references/execute-work-edges.md +9 -3
- package/skills/handoff/SKILL.md +44 -54
- package/skills/handoff/references/handoff-templates.md +15 -6
- package/skills/review-artifact/SKILL.md +80 -127
- package/skills/review-artifact/references/independent-artifact-reviewer-contract.md +1 -1
- package/skills/review-work/SKILL.md +65 -115
- package/skills/review-work/references/independent-work-reviewer-contract.md +1 -1
- package/skills/simplify-code/SKILL.md +47 -100
- package/skills/track-work/SKILL.md +128 -119
- package/skills/track-work/references/working-record-format.md +14 -2
- package/skills/verify-work/SKILL.md +58 -62
- package/skills/verify-work/references/integration-evidence.md +4 -2
- package/skills/verify-work/references/test-design.md +89 -0
- package/skills/workflow/SKILL.md +73 -119
- package/skills/write-plan/SKILL.md +46 -77
- package/skills/write-plan/references/plan-shapes.md +5 -5
- package/skills/write-spec/SKILL.md +51 -63
- package/skills/write-spec/references/artifact-standards.md +2 -2
- package/skills/write-spec/references/decision-records.md +3 -2
- package/skills/write-spec/references/spec-shapes.md +15 -0
- package/pi-extension/dist/cognitive-routing/commands.js +0 -96
- package/pi-extension/dist/cognitive-routing/config.js +0 -232
- package/pi-extension/dist/cognitive-routing/controller.js +0 -1292
- package/pi-extension/dist/cognitive-routing/history.js +0 -451
- package/pi-extension/dist/cognitive-routing/host.js +0 -188
- package/pi-extension/dist/cognitive-routing/pi-routing-session.js +0 -414
- package/pi-extension/dist/cognitive-routing/pi-session-control.js +0 -320
- package/pi-extension/dist/cognitive-routing/runtime.js +0 -43
- package/pi-extension/dist/cognitive-routing/tool.js +0 -212
- package/pi-extension/dist/cognitive-routing/types.js +0 -6
- package/pi-extension/src/cognitive-routing/commands.ts +0 -129
- package/pi-extension/src/cognitive-routing/config.ts +0 -303
- package/pi-extension/src/cognitive-routing/controller.ts +0 -1628
- package/pi-extension/src/cognitive-routing/history.ts +0 -650
- package/pi-extension/src/cognitive-routing/host.ts +0 -266
- package/pi-extension/src/cognitive-routing/pi-routing-session.ts +0 -538
- package/pi-extension/src/cognitive-routing/pi-session-control.ts +0 -444
- package/pi-extension/src/cognitive-routing/runtime.ts +0 -57
- package/pi-extension/src/cognitive-routing/tool.ts +0 -273
- package/pi-extension/src/cognitive-routing/types.ts +0 -89
- package/pi-extension/tests/cognitive-routing/commands.test.js +0 -134
- package/pi-extension/tests/cognitive-routing/controller.test.js +0 -1128
- package/pi-extension/tests/cognitive-routing/extension-lifecycle.test.js +0 -1142
- package/pi-extension/tests/cognitive-routing/history.test.js +0 -274
- package/pi-extension/tests/cognitive-routing/host-identity.test.js +0 -63
- package/pi-extension/tests/cognitive-routing/pi-normal-lifecycle.test.js +0 -434
- package/pi-extension/tests/cognitive-routing/pi-session-control.test.js +0 -174
- package/pi-extension/tests/cognitive-routing/pi-session-history.test.js +0 -91
- package/pi-extension/tests/cognitive-routing/profile-resolution.test.js +0 -279
- package/pi-extension/tests/cognitive-routing/tool.test.js +0 -230
- package/skills/tdd/SKILL.md +0 -143
- package/skills/tdd/references/test-design.md +0 -96
- /package/pi-extension/tests/{cognitive-routing/host-fixture.js → fixtures/pi-host.js} +0 -0
|
@@ -2,8 +2,8 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import { resolveCognitiveRoutingState } from "../cognitive-routing/
|
|
6
|
-
import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing/
|
|
5
|
+
import { resolveCognitiveRoutingState } from "../cognitive-routing-v2/config.js";
|
|
6
|
+
import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing-v2/config.js";
|
|
7
7
|
|
|
8
8
|
export const WORKFLOW_COMMANDS = [
|
|
9
9
|
{ command: "discuss", skill: "discuss" },
|
|
@@ -47,7 +47,6 @@ export const FREEFLOW_MODEL_SKILL_NAMES = [
|
|
|
47
47
|
"setup-freeflow",
|
|
48
48
|
"launch-work",
|
|
49
49
|
"simplify-code",
|
|
50
|
-
"tdd",
|
|
51
50
|
"track-work",
|
|
52
51
|
"verify-work",
|
|
53
52
|
"workflow",
|
|
@@ -87,7 +86,6 @@ type SessionCoreOverrides = Partial<Record<SessionCoreKey, boolean>>;
|
|
|
87
86
|
const SESSION_OVERRIDES_ENTRY = "freeflow-session-overrides";
|
|
88
87
|
const SESSION_CORE_KEYS = new Set<SessionCoreKey>(["enabled", "contextVirtualization", "conversationHistory"]);
|
|
89
88
|
|
|
90
|
-
export const COGNITIVE_ROUTING_SWITCH_TOOL_NAME = "freeflow_switch_profile";
|
|
91
89
|
export const FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-runtime-state";
|
|
92
90
|
export const COGNITIVE_ROUTING_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-cognitive-routing-runtime-state";
|
|
93
91
|
export const WORKFLOW_BOOTSTRAP_MESSAGE_TYPE = "freeflow-workflow-bootstrap";
|
|
@@ -101,6 +99,33 @@ export function isPromptAvailable(value: unknown): value is string {
|
|
|
101
99
|
return typeof value === "string" && value.trim().length > 0;
|
|
102
100
|
}
|
|
103
101
|
|
|
102
|
+
// pi-subagents stamps child system prompts with this tag before binding extensions.
|
|
103
|
+
// Keeping detection at the prompt boundary avoids a process-global child flag.
|
|
104
|
+
export const SUBAGENT_AGENT_TAG = /<active_agent\s+name="[^"]+"\s*\/>/;
|
|
105
|
+
export const FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER = "<!-- freeflow-subagent-capabilities: disabled -->";
|
|
106
|
+
const SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE = "Optional Freeflow capabilities are disabled for subagents.";
|
|
107
|
+
|
|
108
|
+
export function isSubagentContext(context: unknown): boolean {
|
|
109
|
+
try {
|
|
110
|
+
const systemPrompt = (context as { getSystemPrompt?: () => unknown })?.getSystemPrompt?.();
|
|
111
|
+
return (
|
|
112
|
+
typeof systemPrompt === "string" &&
|
|
113
|
+
(SUBAGENT_AGENT_TAG.test(systemPrompt) || systemPrompt.includes(FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER))
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function disableSubagentCapability(capability: any): any {
|
|
121
|
+
return {
|
|
122
|
+
...capability,
|
|
123
|
+
enabled: false,
|
|
124
|
+
effective: false,
|
|
125
|
+
blockingReason: { code: "disabled", message: SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
export function hasUsableMandatoryPrompts(freeflowContext: any): boolean {
|
|
105
130
|
return (
|
|
106
131
|
isPromptAvailable(freeflowContext?.corePrompt) && isPromptAvailable(freeflowContext?.interactionContractPrompt)
|
|
@@ -432,7 +457,8 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
432
457
|
const enabled = layers.configured && effectiveCore.config.enabled;
|
|
433
458
|
const contextVirtualizationConfigEnabled = effectiveCore.config.contextVirtualization;
|
|
434
459
|
const conversationHistoryConfigEnabled = effectiveCore.config.conversationHistory;
|
|
435
|
-
const
|
|
460
|
+
const subagentContext = isSubagentContext(host);
|
|
461
|
+
const hostSupportsCognitiveRouting = !subagentContext && supportsCognitiveRoutingModelRegistry(host);
|
|
436
462
|
const configuredCognitiveRouting = await resolveCognitiveRoutingState(
|
|
437
463
|
layers.repository.parsed,
|
|
438
464
|
layers.local.parsed,
|
|
@@ -452,7 +478,7 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
452
478
|
effective: false,
|
|
453
479
|
blockingReason: disabledReason,
|
|
454
480
|
};
|
|
455
|
-
|
|
481
|
+
const capabilityState = {
|
|
456
482
|
configured: layers.configured,
|
|
457
483
|
repositoryConfigured: layers.repositoryConfigured,
|
|
458
484
|
configExists: layers.repository.exists,
|
|
@@ -473,6 +499,14 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
473
499
|
hostSupportsCognitiveRouting,
|
|
474
500
|
cognitiveRouting,
|
|
475
501
|
};
|
|
502
|
+
if (!subagentContext) return capabilityState;
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
...capabilityState,
|
|
506
|
+
contextVirtualization: disableSubagentCapability(capabilityState.contextVirtualization),
|
|
507
|
+
conversationHistory: disableSubagentCapability(capabilityState.conversationHistory),
|
|
508
|
+
cognitiveRouting: disableSubagentCapability(capabilityState.cognitiveRouting),
|
|
509
|
+
};
|
|
476
510
|
}
|
|
477
511
|
|
|
478
512
|
export const readRuntimeState = readCapabilityState;
|
|
@@ -526,8 +560,8 @@ export function setFreeflowStatus(
|
|
|
526
560
|
} else if (cognitiveRoutingActive) {
|
|
527
561
|
const profile = cognitiveRoutingRuntime.activeProfile;
|
|
528
562
|
const control =
|
|
529
|
-
cognitiveRoutingRuntime.controlMode === "manual-
|
|
530
|
-
cognitiveRoutingRuntime.controlMode === "manual-
|
|
563
|
+
cognitiveRoutingRuntime.controlMode === "manual-executor" ||
|
|
564
|
+
cognitiveRoutingRuntime.controlMode === "manual-coordinator"
|
|
531
565
|
? "manual hold"
|
|
532
566
|
: "automatic";
|
|
533
567
|
active.push(`${profile} · ${control}`);
|
|
@@ -542,8 +576,8 @@ export function setFreeflowStatus(
|
|
|
542
576
|
) {
|
|
543
577
|
const startupProfile =
|
|
544
578
|
cognitiveRouting.sessionStart?.control === "manual"
|
|
545
|
-
? (cognitiveRouting.sessionStart.profile ?? "
|
|
546
|
-
: "
|
|
579
|
+
? (cognitiveRouting.sessionStart.profile ?? "coordinator")
|
|
580
|
+
: "coordinator";
|
|
547
581
|
active.push(`${startupProfile} · pending`);
|
|
548
582
|
} else {
|
|
549
583
|
const reason =
|
|
@@ -572,6 +606,8 @@ type CognitiveRoutingRuntimeSnapshot = {
|
|
|
572
606
|
controlMode?: unknown;
|
|
573
607
|
};
|
|
574
608
|
|
|
609
|
+
type CognitiveRoutingProjectionMode = "disabled" | "enabled" | "manual-bypass" | "blocked" | "unavailable" | "pending";
|
|
610
|
+
|
|
575
611
|
type FreeflowContextMessage = {
|
|
576
612
|
customType?: unknown;
|
|
577
613
|
[key: string]: unknown;
|
|
@@ -579,13 +615,13 @@ type FreeflowContextMessage = {
|
|
|
579
615
|
|
|
580
616
|
function publicCognitiveRoutingControl(controlMode) {
|
|
581
617
|
if (controlMode === "automatic") return "automatic";
|
|
582
|
-
if (controlMode === "manual-
|
|
618
|
+
if (controlMode === "manual-executor" || controlMode === "manual-coordinator") return "manual";
|
|
583
619
|
return "unavailable";
|
|
584
620
|
}
|
|
585
621
|
|
|
586
622
|
function publicCognitiveRoutingProfile(activeProfile, effective) {
|
|
587
623
|
if (effective !== true) return "unavailable";
|
|
588
|
-
return activeProfile === "
|
|
624
|
+
return activeProfile === "executor" || activeProfile === "coordinator" ? activeProfile : "unavailable";
|
|
589
625
|
}
|
|
590
626
|
|
|
591
627
|
function publicCapabilityStatus(capability) {
|
|
@@ -601,10 +637,27 @@ function publicCognitiveRoutingStatus(capability, runtime) {
|
|
|
601
637
|
return publicCapabilityStatus(capability);
|
|
602
638
|
}
|
|
603
639
|
|
|
640
|
+
function publicCognitiveRoutingProjectionMode(
|
|
641
|
+
capability: any,
|
|
642
|
+
runtime: CognitiveRoutingRuntimeSnapshot | undefined,
|
|
643
|
+
projectionFailure?: string,
|
|
644
|
+
): CognitiveRoutingProjectionMode {
|
|
645
|
+
if (capability?.projection !== true) return "disabled";
|
|
646
|
+
if (capability?.effective !== true) return "unavailable";
|
|
647
|
+
if (runtime?.controlMode === "manual-executor" || runtime?.controlMode === "manual-coordinator") {
|
|
648
|
+
return "manual-bypass";
|
|
649
|
+
}
|
|
650
|
+
if (projectionFailure) return "blocked";
|
|
651
|
+
if (runtime?.runtimeStatus === "inactive" || runtime?.runtimeStatus === "blocked") return "unavailable";
|
|
652
|
+
if (runtime?.effective === true && runtime.controlMode === "automatic") return "enabled";
|
|
653
|
+
return "pending";
|
|
654
|
+
}
|
|
655
|
+
|
|
604
656
|
export function freeflowRuntimeStateMessage(
|
|
605
657
|
capabilityState,
|
|
606
658
|
cognitiveRoutingRuntime: CognitiveRoutingRuntimeSnapshot | undefined = undefined,
|
|
607
659
|
freeflowContext = undefined,
|
|
660
|
+
options: { projectionFailure?: string } = {},
|
|
608
661
|
) {
|
|
609
662
|
const cognitiveRoutingEffective = capabilityState?.cognitiveRouting?.effective === true;
|
|
610
663
|
const profile = publicCognitiveRoutingProfile(
|
|
@@ -613,6 +666,11 @@ export function freeflowRuntimeStateMessage(
|
|
|
613
666
|
);
|
|
614
667
|
const control =
|
|
615
668
|
profile === "unavailable" ? "unavailable" : publicCognitiveRoutingControl(cognitiveRoutingRuntime?.controlMode);
|
|
669
|
+
const projectionMode = publicCognitiveRoutingProjectionMode(
|
|
670
|
+
capabilityState?.cognitiveRouting,
|
|
671
|
+
cognitiveRoutingRuntime,
|
|
672
|
+
options.projectionFailure,
|
|
673
|
+
);
|
|
616
674
|
const mandatoryPromptAvailable = capabilityState?.enabled !== true || hasUsableMandatoryPrompts(freeflowContext);
|
|
617
675
|
const freeflowStatus = capabilityState?.configured
|
|
618
676
|
? capabilityState.enabled
|
|
@@ -642,6 +700,7 @@ export function freeflowRuntimeStateMessage(
|
|
|
642
700
|
"Cognitive Routing:",
|
|
643
701
|
`- Control: \`${control}\``,
|
|
644
702
|
`- Profile: \`${profile}\``,
|
|
703
|
+
`- Projection: \`${projectionMode}\``,
|
|
645
704
|
].join("\n"),
|
|
646
705
|
display: false,
|
|
647
706
|
details: { source: "provider-request-runtime-state" },
|
|
@@ -677,10 +736,12 @@ export function withFreeflowRuntimeState(
|
|
|
677
736
|
capabilityState,
|
|
678
737
|
cognitiveRoutingRuntime: CognitiveRoutingRuntimeSnapshot | undefined = undefined,
|
|
679
738
|
freeflowContext = undefined,
|
|
680
|
-
options: { force?: boolean } = {},
|
|
739
|
+
options: { force?: boolean; projectionFailure?: string } = {},
|
|
681
740
|
) {
|
|
682
741
|
const source = Array.isArray(messages) ? messages : [];
|
|
683
|
-
const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext
|
|
742
|
+
const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext, {
|
|
743
|
+
projectionFailure: options.projectionFailure,
|
|
744
|
+
});
|
|
684
745
|
const runtimeStateMessages = source.filter(
|
|
685
746
|
(message) =>
|
|
686
747
|
message?.customType === FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE ||
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { activeReadOnlySessionBranch, readOnlySessionSnapshot, ReadOnlySessionError } from "./read-only-session.js";
|
|
3
|
+
import {
|
|
4
|
+
ROUTING_ENTRY,
|
|
5
|
+
RoutingError,
|
|
6
|
+
canonical,
|
|
7
|
+
eventKey,
|
|
8
|
+
eventValue,
|
|
9
|
+
requireCondition as check,
|
|
10
|
+
type EventData,
|
|
11
|
+
type NativeEntry,
|
|
12
|
+
type RoutingEvent,
|
|
13
|
+
} from "../cognitive-routing-v2/types.js";
|
|
14
|
+
import { parseRoutingEvent, reduce, replay } from "../cognitive-routing-v2/state.js";
|
|
15
|
+
|
|
16
|
+
export interface SessionReader {
|
|
17
|
+
getSessionId(): string;
|
|
18
|
+
getSessionFile(): string | undefined;
|
|
19
|
+
getBranch(): readonly NativeEntry[];
|
|
20
|
+
getEntries(): readonly NativeEntry[];
|
|
21
|
+
getLeafId(): string | null;
|
|
22
|
+
}
|
|
23
|
+
export class EventStore {
|
|
24
|
+
private observed = new Map<string, { value: string; eventId: string; entryId: string }>();
|
|
25
|
+
private attempted = new Map<string, RoutingEvent>();
|
|
26
|
+
private fault?: string;
|
|
27
|
+
private ready = false;
|
|
28
|
+
private cachedEntries: readonly NativeEntry[] = [];
|
|
29
|
+
private cachedState = replay([]);
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly pi: { appendEntry(type: string, data: unknown): void },
|
|
32
|
+
readonly reader: SessionReader,
|
|
33
|
+
) {}
|
|
34
|
+
get blocked(): string | undefined {
|
|
35
|
+
return this.fault ?? (!this.ready ? "acknowledgment_unclassified" : undefined);
|
|
36
|
+
}
|
|
37
|
+
state() {
|
|
38
|
+
const entries = this.reader.getBranch();
|
|
39
|
+
const prefix =
|
|
40
|
+
this.cachedEntries.length <= entries.length && this.cachedEntries.every((entry, i) => entry === entries[i]);
|
|
41
|
+
if (!prefix) {
|
|
42
|
+
this.cachedState = replay(entries);
|
|
43
|
+
} else {
|
|
44
|
+
let state = this.cachedState;
|
|
45
|
+
for (const entry of entries.slice(this.cachedEntries.length))
|
|
46
|
+
if (entry.type === "custom" && entry.customType === ROUTING_ENTRY)
|
|
47
|
+
state = reduce(state, parseRoutingEvent(entry.data));
|
|
48
|
+
this.cachedState = state;
|
|
49
|
+
}
|
|
50
|
+
this.cachedEntries = [...entries];
|
|
51
|
+
return this.cachedState;
|
|
52
|
+
}
|
|
53
|
+
block(reason: string): void {
|
|
54
|
+
this.fault = reason;
|
|
55
|
+
this.ready = false;
|
|
56
|
+
}
|
|
57
|
+
make(data: EventData, operationId: string = randomUUID(), stepId: string = data.type): RoutingEvent {
|
|
58
|
+
return parseRoutingEvent({
|
|
59
|
+
version: 2,
|
|
60
|
+
eventId: randomUUID(),
|
|
61
|
+
operationId,
|
|
62
|
+
stepId,
|
|
63
|
+
recordedSessionId: this.reader.getSessionId(),
|
|
64
|
+
data,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async reconcile(): Promise<void> {
|
|
68
|
+
const acknowledged = this.ready && !this.fault;
|
|
69
|
+
this.ready = false;
|
|
70
|
+
const branch = this.reader.getBranch();
|
|
71
|
+
const live = branch.filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
|
|
72
|
+
try {
|
|
73
|
+
const ids = new Set<string>();
|
|
74
|
+
let parent: string | null = null;
|
|
75
|
+
for (const entry of branch) {
|
|
76
|
+
check(
|
|
77
|
+
typeof entry.id === "string" && !ids.has(entry.id) && entry.parentId === parent,
|
|
78
|
+
"invalid_native_ancestry",
|
|
79
|
+
);
|
|
80
|
+
ids.add(entry.id);
|
|
81
|
+
parent = entry.id;
|
|
82
|
+
}
|
|
83
|
+
check(parent === this.reader.getLeafId(), "invalid_native_ancestry");
|
|
84
|
+
if (
|
|
85
|
+
acknowledged &&
|
|
86
|
+
live.every((entry) => {
|
|
87
|
+
const event = parseRoutingEvent(entry.data);
|
|
88
|
+
const receipt = this.observed.get(eventKey(event));
|
|
89
|
+
return (
|
|
90
|
+
receipt?.entryId === entry.id && receipt.eventId === event.eventId && receipt.value === eventValue(event)
|
|
91
|
+
);
|
|
92
|
+
})
|
|
93
|
+
) {
|
|
94
|
+
this.ready = true;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!live.length && !this.attempted.size) {
|
|
98
|
+
this.observed.clear();
|
|
99
|
+
this.fault = undefined;
|
|
100
|
+
this.ready = true;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const path = this.reader.getSessionFile();
|
|
104
|
+
check(path, "persisted_snapshot_unavailable", "Existing or uncertain routing state needs a persisted snapshot.");
|
|
105
|
+
const snapshot = await readOnlySessionSnapshot(path);
|
|
106
|
+
check(snapshot.sessionId === this.reader.getSessionId(), "session_identity_changed");
|
|
107
|
+
const persistedBranch = activeReadOnlySessionBranch(snapshot, this.reader.getLeafId());
|
|
108
|
+
check(
|
|
109
|
+
persistedBranch.length === branch.length && persistedBranch.every((e, i) => e.id === branch[i].id),
|
|
110
|
+
"snapshot_ancestry_mismatch",
|
|
111
|
+
"Live branch is not the complete persisted ancestry for its claimed leaf.",
|
|
112
|
+
);
|
|
113
|
+
replay(persistedBranch as NativeEntry[]);
|
|
114
|
+
const disk = new Map(persistedBranch.map((e) => [e.id, e]));
|
|
115
|
+
const known = new Map<string, { value: string; eventId: string; entryId: string }>();
|
|
116
|
+
// Compare the live selected ancestry, not whichever branch was last written on disk.
|
|
117
|
+
for (const entry of branch) {
|
|
118
|
+
const captured = disk.get(entry.id);
|
|
119
|
+
check(
|
|
120
|
+
captured && canonical(captured) === canonical(entry),
|
|
121
|
+
"snapshot_divergence",
|
|
122
|
+
"Live ancestry is not fully established by the fresh persisted snapshot; reconcile the session before retrying.",
|
|
123
|
+
);
|
|
124
|
+
if (entry.type === "custom" && entry.customType === ROUTING_ENTRY) {
|
|
125
|
+
const event = parseRoutingEvent(entry.data);
|
|
126
|
+
known.set(eventKey(event), { value: eventValue(event), eventId: event.eventId, entryId: entry.id });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const [key, event] of this.attempted)
|
|
130
|
+
check(
|
|
131
|
+
known.get(key)?.value === eventValue(event) && known.get(key)?.eventId === event.eventId,
|
|
132
|
+
"append_acknowledgment_uncertain",
|
|
133
|
+
"The attempted routing event is not established by persisted readback.",
|
|
134
|
+
);
|
|
135
|
+
this.observed = known;
|
|
136
|
+
this.attempted.clear();
|
|
137
|
+
this.fault = undefined;
|
|
138
|
+
this.ready = true;
|
|
139
|
+
this.cachedEntries = [];
|
|
140
|
+
this.cachedState = replay([]);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
this.block(
|
|
143
|
+
error instanceof RoutingError
|
|
144
|
+
? error.code
|
|
145
|
+
: error instanceof ReadOnlySessionError
|
|
146
|
+
? `${error.code}: ${error.message}`
|
|
147
|
+
: "snapshot_unavailable",
|
|
148
|
+
);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
append(event: RoutingEvent): RoutingEvent {
|
|
153
|
+
const value = parseRoutingEvent(event),
|
|
154
|
+
key = eventKey(value),
|
|
155
|
+
body = eventValue(value);
|
|
156
|
+
if (this.blocked) throw new RoutingError("acknowledgment_uncertain", this.blocked);
|
|
157
|
+
const prior = this.state().events.get(key);
|
|
158
|
+
if (prior) {
|
|
159
|
+
check(eventValue(prior) === body, "operation_conflict");
|
|
160
|
+
const occurrence = this.reader
|
|
161
|
+
.getBranch()
|
|
162
|
+
.find(
|
|
163
|
+
(entry) =>
|
|
164
|
+
entry.type === "custom" &&
|
|
165
|
+
entry.customType === ROUTING_ENTRY &&
|
|
166
|
+
(entry.data as any)?.eventId === prior.eventId,
|
|
167
|
+
);
|
|
168
|
+
if (
|
|
169
|
+
this.observed.get(key)?.value !== body ||
|
|
170
|
+
this.observed.get(key)?.eventId !== prior.eventId ||
|
|
171
|
+
this.observed.get(key)?.entryId !== occurrence?.id
|
|
172
|
+
) {
|
|
173
|
+
this.block("prior_acknowledgment_unclassified");
|
|
174
|
+
throw new RoutingError("acknowledgment_uncertain", this.blocked!);
|
|
175
|
+
}
|
|
176
|
+
return prior;
|
|
177
|
+
}
|
|
178
|
+
// Validate the whole state transition before mutating Pi's session memory.
|
|
179
|
+
const candidate = reduce(this.state(), value);
|
|
180
|
+
const baseline = this.cachedEntries;
|
|
181
|
+
this.attempted.set(key, value);
|
|
182
|
+
try {
|
|
183
|
+
this.pi.appendEntry(ROUTING_ENTRY, value);
|
|
184
|
+
const found = this.reader
|
|
185
|
+
.getBranch()
|
|
186
|
+
.find(
|
|
187
|
+
(e) => e.type === "custom" && e.customType === ROUTING_ENTRY && (e.data as any)?.eventId === value.eventId,
|
|
188
|
+
);
|
|
189
|
+
check(found && eventValue(parseRoutingEvent(found.data)) === body, "append_not_observed");
|
|
190
|
+
this.observed.set(key, { value: body, eventId: value.eventId, entryId: found.id });
|
|
191
|
+
this.attempted.delete(key);
|
|
192
|
+
const branch = this.reader.getBranch();
|
|
193
|
+
const delta = branch.slice(baseline.length).filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
|
|
194
|
+
if (baseline.every((e, i) => branch[i] === e) && delta.length === 1 && delta[0].id === found!.id) {
|
|
195
|
+
// Publish the already validated candidate only after acknowledged native append.
|
|
196
|
+
this.cachedState = candidate;
|
|
197
|
+
this.cachedEntries = [...branch];
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
this.block("append_acknowledgment_uncertain");
|
|
202
|
+
throw new RoutingError(
|
|
203
|
+
"acknowledgment_uncertain",
|
|
204
|
+
error instanceof Error ? error.message : "Pi append/readback did not acknowledge the event.",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
4
|
+
|
|
5
|
+
export interface ReadOnlySessionEntry {
|
|
6
|
+
type: string;
|
|
7
|
+
id: string;
|
|
8
|
+
parentId?: string | null;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface ReadOnlySessionSnapshot {
|
|
12
|
+
path: string;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
version: number;
|
|
15
|
+
hash: string;
|
|
16
|
+
entries: readonly ReadOnlySessionEntry[];
|
|
17
|
+
}
|
|
18
|
+
export class ReadOnlySessionError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
readonly code: string,
|
|
21
|
+
message: string,
|
|
22
|
+
readonly line?: number,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "ReadOnlySessionError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SESSION_READ_LIMITS = {
|
|
30
|
+
maxBytes: 512 * 1024 * 1024,
|
|
31
|
+
maxEntryBytes: 128 * 1024 * 1024,
|
|
32
|
+
maxEntries: 250000,
|
|
33
|
+
};
|
|
34
|
+
function decodeEntry(text: string, line: number, seen: Set<string>): ReadOnlySessionEntry {
|
|
35
|
+
let value: any;
|
|
36
|
+
try {
|
|
37
|
+
value = JSON.parse(text);
|
|
38
|
+
} catch {
|
|
39
|
+
throw new ReadOnlySessionError("invalid_json", "Incomplete or invalid session JSON.", line);
|
|
40
|
+
}
|
|
41
|
+
if (!value || Array.isArray(value) || typeof value !== "object" || typeof value.type !== "string")
|
|
42
|
+
throw new ReadOnlySessionError("invalid_entry", "Invalid session entry.", line);
|
|
43
|
+
if (line === 1) {
|
|
44
|
+
if (
|
|
45
|
+
value.type !== "session" ||
|
|
46
|
+
typeof value.id !== "string" ||
|
|
47
|
+
!value.id ||
|
|
48
|
+
![2, 3].includes(value.version) ||
|
|
49
|
+
typeof value.cwd !== "string"
|
|
50
|
+
)
|
|
51
|
+
throw new ReadOnlySessionError("invalid_header", "A supported native session header is required.", line);
|
|
52
|
+
} else {
|
|
53
|
+
if (value.type === "session" || typeof value.id !== "string" || !value.id || seen.has(value.id))
|
|
54
|
+
throw new ReadOnlySessionError("invalid_id", "Missing, duplicate, or misplaced session identity.", line);
|
|
55
|
+
if (value.parentId !== null && (typeof value.parentId !== "string" || !seen.has(value.parentId)))
|
|
56
|
+
throw new ReadOnlySessionError("invalid_parent", "Parent is absent or occurs after its child.", line);
|
|
57
|
+
seen.add(value.id);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
export function parseReadOnlySessionText(text: string): readonly ReadOnlySessionEntry[] {
|
|
62
|
+
const lines = text.split("\n");
|
|
63
|
+
if (lines.at(-1) === "") lines.pop();
|
|
64
|
+
if (!lines.length || !lines[0]) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
return lines.map((line, i) => decodeEntry(line, i + 1, seen));
|
|
67
|
+
}
|
|
68
|
+
export async function readOnlySessionSnapshot(
|
|
69
|
+
path: string,
|
|
70
|
+
options: { maxBytes?: number; maxEntryBytes?: number; maxEntries?: number } = {},
|
|
71
|
+
): Promise<ReadOnlySessionSnapshot> {
|
|
72
|
+
const limits = { ...SESSION_READ_LIMITS, ...options };
|
|
73
|
+
if (Object.values(limits).some((n) => !Number.isSafeInteger(n) || n <= 0))
|
|
74
|
+
throw new ReadOnlySessionError("invalid_limit", "Invalid snapshot limit.");
|
|
75
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
76
|
+
try {
|
|
77
|
+
file = await open(path, "r");
|
|
78
|
+
const before = await file.stat({ bigint: true });
|
|
79
|
+
if (!before.isFile()) throw new ReadOnlySessionError("read_limit", "Session is not a regular file.");
|
|
80
|
+
if (before.size > BigInt(limits.maxBytes))
|
|
81
|
+
throw new ReadOnlySessionError(
|
|
82
|
+
"read_limit",
|
|
83
|
+
`Session exceeds the supported ${limits.maxBytes} byte snapshot limit.`,
|
|
84
|
+
);
|
|
85
|
+
const hash = createHash("sha256"),
|
|
86
|
+
entries: ReadOnlySessionEntry[] = [],
|
|
87
|
+
seen = new Set<string>();
|
|
88
|
+
let total = 0,
|
|
89
|
+
lineBytes = 0,
|
|
90
|
+
parts: Buffer[] = [];
|
|
91
|
+
const append = (part: Buffer) => {
|
|
92
|
+
lineBytes += part.length;
|
|
93
|
+
if (lineBytes > limits.maxEntryBytes)
|
|
94
|
+
throw new ReadOnlySessionError(
|
|
95
|
+
"entry_limit",
|
|
96
|
+
`Session entry exceeds ${limits.maxEntryBytes} bytes.`,
|
|
97
|
+
entries.length + 1,
|
|
98
|
+
);
|
|
99
|
+
if (part.length) parts.push(part);
|
|
100
|
+
};
|
|
101
|
+
const decode = () => {
|
|
102
|
+
if (entries.length >= limits.maxEntries)
|
|
103
|
+
throw new ReadOnlySessionError("entry_count_limit", `Session exceeds ${limits.maxEntries} entries.`);
|
|
104
|
+
let text: string;
|
|
105
|
+
try {
|
|
106
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, lineBytes));
|
|
107
|
+
} catch {
|
|
108
|
+
throw new ReadOnlySessionError("invalid_encoding", "Session is not complete UTF-8.", entries.length + 1);
|
|
109
|
+
}
|
|
110
|
+
entries.push(decodeEntry(text, entries.length + 1, seen));
|
|
111
|
+
parts = [];
|
|
112
|
+
lineBytes = 0;
|
|
113
|
+
};
|
|
114
|
+
for (;;) {
|
|
115
|
+
const buffer = Buffer.alloc(64 * 1024),
|
|
116
|
+
{ bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
117
|
+
if (!bytesRead) break;
|
|
118
|
+
total += bytesRead;
|
|
119
|
+
if (total > limits.maxBytes)
|
|
120
|
+
throw new ReadOnlySessionError("read_limit", "Session grew beyond the supported snapshot limit.");
|
|
121
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
122
|
+
hash.update(bytes);
|
|
123
|
+
let start = 0,
|
|
124
|
+
end: number;
|
|
125
|
+
while ((end = bytes.indexOf(10, start)) !== -1) {
|
|
126
|
+
append(bytes.subarray(start, end));
|
|
127
|
+
decode();
|
|
128
|
+
start = end + 1;
|
|
129
|
+
}
|
|
130
|
+
append(bytes.subarray(start));
|
|
131
|
+
}
|
|
132
|
+
if (lineBytes) decode();
|
|
133
|
+
if (!entries.length) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
|
|
134
|
+
const after = await file.stat({ bigint: true }),
|
|
135
|
+
current = await stat(path, { bigint: true });
|
|
136
|
+
const signature = (v: typeof before) => [v.dev, v.ino, v.size, v.mtimeNs, v.ctimeNs].join(":");
|
|
137
|
+
if (
|
|
138
|
+
signature(before) !== signature(after) ||
|
|
139
|
+
signature(after) !== signature(current) ||
|
|
140
|
+
after.size !== BigInt(total)
|
|
141
|
+
)
|
|
142
|
+
throw new ReadOnlySessionError("source_changed", "Session changed during snapshot capture.");
|
|
143
|
+
return { path, sessionId: entries[0].id, version: entries[0].version as number, hash: hash.digest("hex"), entries };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error instanceof ReadOnlySessionError) throw error;
|
|
146
|
+
throw new ReadOnlySessionError("read_failed", error instanceof Error ? error.message : String(error));
|
|
147
|
+
} finally {
|
|
148
|
+
await file?.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function activeReadOnlySessionBranch(
|
|
153
|
+
snapshot: ReadOnlySessionSnapshot,
|
|
154
|
+
leafId: string | null,
|
|
155
|
+
): readonly ReadOnlySessionEntry[] {
|
|
156
|
+
if (leafId === null) return [];
|
|
157
|
+
const byId = new Map(snapshot.entries.slice(1).map((entry) => [entry.id, entry]));
|
|
158
|
+
const path: ReadOnlySessionEntry[] = [];
|
|
159
|
+
const seen = new Set<string>();
|
|
160
|
+
let cursor: string | null = leafId;
|
|
161
|
+
while (cursor !== null) {
|
|
162
|
+
const entry = byId.get(cursor);
|
|
163
|
+
if (!entry || seen.has(cursor))
|
|
164
|
+
throw new ReadOnlySessionError("ancestry_unavailable", "Requested native ancestry is unavailable.");
|
|
165
|
+
seen.add(cursor);
|
|
166
|
+
path.push(entry);
|
|
167
|
+
cursor = entry.parentId as string | null;
|
|
168
|
+
}
|
|
169
|
+
return path.reverse();
|
|
170
|
+
}
|