@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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { resolveCognitiveRoutingState } from "../cognitive-routing/
|
|
5
|
-
import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing/
|
|
4
|
+
import { resolveCognitiveRoutingState } from "../cognitive-routing-v2/config.js";
|
|
5
|
+
import { supportsCognitiveRoutingModelRegistry } from "../cognitive-routing-v2/config.js";
|
|
6
6
|
export const WORKFLOW_COMMANDS = [
|
|
7
7
|
{ command: "discuss", skill: "discuss" },
|
|
8
8
|
{ command: "action-selection", skill: "action-selection" },
|
|
@@ -43,7 +43,6 @@ export const FREEFLOW_MODEL_SKILL_NAMES = [
|
|
|
43
43
|
"setup-freeflow",
|
|
44
44
|
"launch-work",
|
|
45
45
|
"simplify-code",
|
|
46
|
-
"tdd",
|
|
47
46
|
"track-work",
|
|
48
47
|
"verify-work",
|
|
49
48
|
"workflow",
|
|
@@ -74,7 +73,6 @@ export function freeflowModelSkillPaths(capabilityState = undefined) {
|
|
|
74
73
|
}
|
|
75
74
|
const SESSION_OVERRIDES_ENTRY = "freeflow-session-overrides";
|
|
76
75
|
const SESSION_CORE_KEYS = new Set(["enabled", "contextVirtualization", "conversationHistory"]);
|
|
77
|
-
export const COGNITIVE_ROUTING_SWITCH_TOOL_NAME = "freeflow_switch_profile";
|
|
78
76
|
export const FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-runtime-state";
|
|
79
77
|
export const COGNITIVE_ROUTING_RUNTIME_STATE_MESSAGE_TYPE = "freeflow-cognitive-routing-runtime-state";
|
|
80
78
|
export const WORKFLOW_BOOTSTRAP_MESSAGE_TYPE = "freeflow-workflow-bootstrap";
|
|
@@ -85,6 +83,30 @@ let currentSessionOverrides = {};
|
|
|
85
83
|
export function isPromptAvailable(value) {
|
|
86
84
|
return typeof value === "string" && value.trim().length > 0;
|
|
87
85
|
}
|
|
86
|
+
// pi-subagents stamps child system prompts with this tag before binding extensions.
|
|
87
|
+
// Keeping detection at the prompt boundary avoids a process-global child flag.
|
|
88
|
+
export const SUBAGENT_AGENT_TAG = /<active_agent\s+name="[^"]+"\s*\/>/;
|
|
89
|
+
export const FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER = "<!-- freeflow-subagent-capabilities: disabled -->";
|
|
90
|
+
const SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE = "Optional Freeflow capabilities are disabled for subagents.";
|
|
91
|
+
export function isSubagentContext(context) {
|
|
92
|
+
try {
|
|
93
|
+
const systemPrompt = context?.getSystemPrompt?.();
|
|
94
|
+
return (
|
|
95
|
+
typeof systemPrompt === "string" &&
|
|
96
|
+
(SUBAGENT_AGENT_TAG.test(systemPrompt) || systemPrompt.includes(FREEFLOW_SUBAGENT_CAPABILITIES_DISABLED_MARKER))
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function disableSubagentCapability(capability) {
|
|
103
|
+
return {
|
|
104
|
+
...capability,
|
|
105
|
+
enabled: false,
|
|
106
|
+
effective: false,
|
|
107
|
+
blockingReason: { code: "disabled", message: SUBAGENT_OPTIONAL_CAPABILITIES_MESSAGE },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
88
110
|
export function hasUsableMandatoryPrompts(freeflowContext) {
|
|
89
111
|
return (
|
|
90
112
|
isPromptAvailable(freeflowContext?.corePrompt) && isPromptAvailable(freeflowContext?.interactionContractPrompt)
|
|
@@ -380,7 +402,8 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
380
402
|
const enabled = layers.configured && effectiveCore.config.enabled;
|
|
381
403
|
const contextVirtualizationConfigEnabled = effectiveCore.config.contextVirtualization;
|
|
382
404
|
const conversationHistoryConfigEnabled = effectiveCore.config.conversationHistory;
|
|
383
|
-
const
|
|
405
|
+
const subagentContext = isSubagentContext(host);
|
|
406
|
+
const hostSupportsCognitiveRouting = !subagentContext && supportsCognitiveRoutingModelRegistry(host);
|
|
384
407
|
const configuredCognitiveRouting = await resolveCognitiveRoutingState(
|
|
385
408
|
layers.repository.parsed,
|
|
386
409
|
layers.local.parsed,
|
|
@@ -400,7 +423,7 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
400
423
|
effective: false,
|
|
401
424
|
blockingReason: disabledReason,
|
|
402
425
|
};
|
|
403
|
-
|
|
426
|
+
const capabilityState = {
|
|
404
427
|
configured: layers.configured,
|
|
405
428
|
repositoryConfigured: layers.repositoryConfigured,
|
|
406
429
|
configExists: layers.repository.exists,
|
|
@@ -421,6 +444,13 @@ export async function readCapabilityState(cwd, host = undefined, extensionHost =
|
|
|
421
444
|
hostSupportsCognitiveRouting,
|
|
422
445
|
cognitiveRouting,
|
|
423
446
|
};
|
|
447
|
+
if (!subagentContext) return capabilityState;
|
|
448
|
+
return {
|
|
449
|
+
...capabilityState,
|
|
450
|
+
contextVirtualization: disableSubagentCapability(capabilityState.contextVirtualization),
|
|
451
|
+
conversationHistory: disableSubagentCapability(capabilityState.conversationHistory),
|
|
452
|
+
cognitiveRouting: disableSubagentCapability(capabilityState.cognitiveRouting),
|
|
453
|
+
};
|
|
424
454
|
}
|
|
425
455
|
export const readRuntimeState = readCapabilityState;
|
|
426
456
|
export function restoreSessionOverrides(ctx) {
|
|
@@ -469,8 +499,8 @@ export function setFreeflowStatus(
|
|
|
469
499
|
} else if (cognitiveRoutingActive) {
|
|
470
500
|
const profile = cognitiveRoutingRuntime.activeProfile;
|
|
471
501
|
const control =
|
|
472
|
-
cognitiveRoutingRuntime.controlMode === "manual-
|
|
473
|
-
cognitiveRoutingRuntime.controlMode === "manual-
|
|
502
|
+
cognitiveRoutingRuntime.controlMode === "manual-executor" ||
|
|
503
|
+
cognitiveRoutingRuntime.controlMode === "manual-coordinator"
|
|
474
504
|
? "manual hold"
|
|
475
505
|
: "automatic";
|
|
476
506
|
active.push(`${profile} · ${control}`);
|
|
@@ -485,8 +515,8 @@ export function setFreeflowStatus(
|
|
|
485
515
|
) {
|
|
486
516
|
const startupProfile =
|
|
487
517
|
cognitiveRouting.sessionStart?.control === "manual"
|
|
488
|
-
? (cognitiveRouting.sessionStart.profile ?? "
|
|
489
|
-
: "
|
|
518
|
+
? (cognitiveRouting.sessionStart.profile ?? "coordinator")
|
|
519
|
+
: "coordinator";
|
|
490
520
|
active.push(`${startupProfile} · pending`);
|
|
491
521
|
} else {
|
|
492
522
|
const reason =
|
|
@@ -507,12 +537,12 @@ export function skillPrompt(skill, args) {
|
|
|
507
537
|
}
|
|
508
538
|
function publicCognitiveRoutingControl(controlMode) {
|
|
509
539
|
if (controlMode === "automatic") return "automatic";
|
|
510
|
-
if (controlMode === "manual-
|
|
540
|
+
if (controlMode === "manual-executor" || controlMode === "manual-coordinator") return "manual";
|
|
511
541
|
return "unavailable";
|
|
512
542
|
}
|
|
513
543
|
function publicCognitiveRoutingProfile(activeProfile, effective) {
|
|
514
544
|
if (effective !== true) return "unavailable";
|
|
515
|
-
return activeProfile === "
|
|
545
|
+
return activeProfile === "executor" || activeProfile === "coordinator" ? activeProfile : "unavailable";
|
|
516
546
|
}
|
|
517
547
|
function publicCapabilityStatus(capability) {
|
|
518
548
|
if (capability?.effective === true) return "active";
|
|
@@ -525,10 +555,22 @@ function publicCognitiveRoutingStatus(capability, runtime) {
|
|
|
525
555
|
if (runtime?.runtimeStatus === "inactive") return "inactive";
|
|
526
556
|
return publicCapabilityStatus(capability);
|
|
527
557
|
}
|
|
558
|
+
function publicCognitiveRoutingProjectionMode(capability, runtime, projectionFailure) {
|
|
559
|
+
if (capability?.projection !== true) return "disabled";
|
|
560
|
+
if (capability?.effective !== true) return "unavailable";
|
|
561
|
+
if (runtime?.controlMode === "manual-executor" || runtime?.controlMode === "manual-coordinator") {
|
|
562
|
+
return "manual-bypass";
|
|
563
|
+
}
|
|
564
|
+
if (projectionFailure) return "blocked";
|
|
565
|
+
if (runtime?.runtimeStatus === "inactive" || runtime?.runtimeStatus === "blocked") return "unavailable";
|
|
566
|
+
if (runtime?.effective === true && runtime.controlMode === "automatic") return "enabled";
|
|
567
|
+
return "pending";
|
|
568
|
+
}
|
|
528
569
|
export function freeflowRuntimeStateMessage(
|
|
529
570
|
capabilityState,
|
|
530
571
|
cognitiveRoutingRuntime = undefined,
|
|
531
572
|
freeflowContext = undefined,
|
|
573
|
+
options = {},
|
|
532
574
|
) {
|
|
533
575
|
const cognitiveRoutingEffective = capabilityState?.cognitiveRouting?.effective === true;
|
|
534
576
|
const profile = publicCognitiveRoutingProfile(
|
|
@@ -537,6 +579,11 @@ export function freeflowRuntimeStateMessage(
|
|
|
537
579
|
);
|
|
538
580
|
const control =
|
|
539
581
|
profile === "unavailable" ? "unavailable" : publicCognitiveRoutingControl(cognitiveRoutingRuntime?.controlMode);
|
|
582
|
+
const projectionMode = publicCognitiveRoutingProjectionMode(
|
|
583
|
+
capabilityState?.cognitiveRouting,
|
|
584
|
+
cognitiveRoutingRuntime,
|
|
585
|
+
options.projectionFailure,
|
|
586
|
+
);
|
|
540
587
|
const mandatoryPromptAvailable = capabilityState?.enabled !== true || hasUsableMandatoryPrompts(freeflowContext);
|
|
541
588
|
const freeflowStatus = capabilityState?.configured
|
|
542
589
|
? capabilityState.enabled
|
|
@@ -565,6 +612,7 @@ export function freeflowRuntimeStateMessage(
|
|
|
565
612
|
"Cognitive Routing:",
|
|
566
613
|
`- Control: \`${control}\``,
|
|
567
614
|
`- Profile: \`${profile}\``,
|
|
615
|
+
`- Projection: \`${projectionMode}\``,
|
|
568
616
|
].join("\n"),
|
|
569
617
|
display: false,
|
|
570
618
|
details: { source: "provider-request-runtime-state" },
|
|
@@ -596,7 +644,9 @@ export function withFreeflowRuntimeState(
|
|
|
596
644
|
options = {},
|
|
597
645
|
) {
|
|
598
646
|
const source = Array.isArray(messages) ? messages : [];
|
|
599
|
-
const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext
|
|
647
|
+
const runtimeState = freeflowRuntimeStateMessage(capabilityState, cognitiveRoutingRuntime, freeflowContext, {
|
|
648
|
+
projectionFailure: options.projectionFailure,
|
|
649
|
+
});
|
|
600
650
|
const runtimeStateMessages = source.filter(
|
|
601
651
|
(message) =>
|
|
602
652
|
message?.customType === FREEFLOW_RUNTIME_STATE_MESSAGE_TYPE ||
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
} from "../cognitive-routing-v2/types.js";
|
|
11
|
+
import { parseRoutingEvent, reduce, replay } from "../cognitive-routing-v2/state.js";
|
|
12
|
+
export class EventStore {
|
|
13
|
+
pi;
|
|
14
|
+
reader;
|
|
15
|
+
observed = new Map();
|
|
16
|
+
attempted = new Map();
|
|
17
|
+
fault;
|
|
18
|
+
ready = false;
|
|
19
|
+
cachedEntries = [];
|
|
20
|
+
cachedState = replay([]);
|
|
21
|
+
constructor(pi, reader) {
|
|
22
|
+
this.pi = pi;
|
|
23
|
+
this.reader = reader;
|
|
24
|
+
}
|
|
25
|
+
get blocked() {
|
|
26
|
+
return this.fault ?? (!this.ready ? "acknowledgment_unclassified" : undefined);
|
|
27
|
+
}
|
|
28
|
+
state() {
|
|
29
|
+
const entries = this.reader.getBranch();
|
|
30
|
+
const prefix =
|
|
31
|
+
this.cachedEntries.length <= entries.length && this.cachedEntries.every((entry, i) => entry === entries[i]);
|
|
32
|
+
if (!prefix) {
|
|
33
|
+
this.cachedState = replay(entries);
|
|
34
|
+
} else {
|
|
35
|
+
let state = this.cachedState;
|
|
36
|
+
for (const entry of entries.slice(this.cachedEntries.length))
|
|
37
|
+
if (entry.type === "custom" && entry.customType === ROUTING_ENTRY)
|
|
38
|
+
state = reduce(state, parseRoutingEvent(entry.data));
|
|
39
|
+
this.cachedState = state;
|
|
40
|
+
}
|
|
41
|
+
this.cachedEntries = [...entries];
|
|
42
|
+
return this.cachedState;
|
|
43
|
+
}
|
|
44
|
+
block(reason) {
|
|
45
|
+
this.fault = reason;
|
|
46
|
+
this.ready = false;
|
|
47
|
+
}
|
|
48
|
+
make(data, operationId = randomUUID(), stepId = data.type) {
|
|
49
|
+
return parseRoutingEvent({
|
|
50
|
+
version: 2,
|
|
51
|
+
eventId: randomUUID(),
|
|
52
|
+
operationId,
|
|
53
|
+
stepId,
|
|
54
|
+
recordedSessionId: this.reader.getSessionId(),
|
|
55
|
+
data,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async reconcile() {
|
|
59
|
+
const acknowledged = this.ready && !this.fault;
|
|
60
|
+
this.ready = false;
|
|
61
|
+
const branch = this.reader.getBranch();
|
|
62
|
+
const live = branch.filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
|
|
63
|
+
try {
|
|
64
|
+
const ids = new Set();
|
|
65
|
+
let parent = null;
|
|
66
|
+
for (const entry of branch) {
|
|
67
|
+
check(
|
|
68
|
+
typeof entry.id === "string" && !ids.has(entry.id) && entry.parentId === parent,
|
|
69
|
+
"invalid_native_ancestry",
|
|
70
|
+
);
|
|
71
|
+
ids.add(entry.id);
|
|
72
|
+
parent = entry.id;
|
|
73
|
+
}
|
|
74
|
+
check(parent === this.reader.getLeafId(), "invalid_native_ancestry");
|
|
75
|
+
if (
|
|
76
|
+
acknowledged &&
|
|
77
|
+
live.every((entry) => {
|
|
78
|
+
const event = parseRoutingEvent(entry.data);
|
|
79
|
+
const receipt = this.observed.get(eventKey(event));
|
|
80
|
+
return (
|
|
81
|
+
receipt?.entryId === entry.id && receipt.eventId === event.eventId && receipt.value === eventValue(event)
|
|
82
|
+
);
|
|
83
|
+
})
|
|
84
|
+
) {
|
|
85
|
+
this.ready = true;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!live.length && !this.attempted.size) {
|
|
89
|
+
this.observed.clear();
|
|
90
|
+
this.fault = undefined;
|
|
91
|
+
this.ready = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const path = this.reader.getSessionFile();
|
|
95
|
+
check(path, "persisted_snapshot_unavailable", "Existing or uncertain routing state needs a persisted snapshot.");
|
|
96
|
+
const snapshot = await readOnlySessionSnapshot(path);
|
|
97
|
+
check(snapshot.sessionId === this.reader.getSessionId(), "session_identity_changed");
|
|
98
|
+
const persistedBranch = activeReadOnlySessionBranch(snapshot, this.reader.getLeafId());
|
|
99
|
+
check(
|
|
100
|
+
persistedBranch.length === branch.length && persistedBranch.every((e, i) => e.id === branch[i].id),
|
|
101
|
+
"snapshot_ancestry_mismatch",
|
|
102
|
+
"Live branch is not the complete persisted ancestry for its claimed leaf.",
|
|
103
|
+
);
|
|
104
|
+
replay(persistedBranch);
|
|
105
|
+
const disk = new Map(persistedBranch.map((e) => [e.id, e]));
|
|
106
|
+
const known = new Map();
|
|
107
|
+
// Compare the live selected ancestry, not whichever branch was last written on disk.
|
|
108
|
+
for (const entry of branch) {
|
|
109
|
+
const captured = disk.get(entry.id);
|
|
110
|
+
check(
|
|
111
|
+
captured && canonical(captured) === canonical(entry),
|
|
112
|
+
"snapshot_divergence",
|
|
113
|
+
"Live ancestry is not fully established by the fresh persisted snapshot; reconcile the session before retrying.",
|
|
114
|
+
);
|
|
115
|
+
if (entry.type === "custom" && entry.customType === ROUTING_ENTRY) {
|
|
116
|
+
const event = parseRoutingEvent(entry.data);
|
|
117
|
+
known.set(eventKey(event), { value: eventValue(event), eventId: event.eventId, entryId: entry.id });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const [key, event] of this.attempted)
|
|
121
|
+
check(
|
|
122
|
+
known.get(key)?.value === eventValue(event) && known.get(key)?.eventId === event.eventId,
|
|
123
|
+
"append_acknowledgment_uncertain",
|
|
124
|
+
"The attempted routing event is not established by persisted readback.",
|
|
125
|
+
);
|
|
126
|
+
this.observed = known;
|
|
127
|
+
this.attempted.clear();
|
|
128
|
+
this.fault = undefined;
|
|
129
|
+
this.ready = true;
|
|
130
|
+
this.cachedEntries = [];
|
|
131
|
+
this.cachedState = replay([]);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
this.block(
|
|
134
|
+
error instanceof RoutingError
|
|
135
|
+
? error.code
|
|
136
|
+
: error instanceof ReadOnlySessionError
|
|
137
|
+
? `${error.code}: ${error.message}`
|
|
138
|
+
: "snapshot_unavailable",
|
|
139
|
+
);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
append(event) {
|
|
144
|
+
const value = parseRoutingEvent(event),
|
|
145
|
+
key = eventKey(value),
|
|
146
|
+
body = eventValue(value);
|
|
147
|
+
if (this.blocked) throw new RoutingError("acknowledgment_uncertain", this.blocked);
|
|
148
|
+
const prior = this.state().events.get(key);
|
|
149
|
+
if (prior) {
|
|
150
|
+
check(eventValue(prior) === body, "operation_conflict");
|
|
151
|
+
const occurrence = this.reader
|
|
152
|
+
.getBranch()
|
|
153
|
+
.find(
|
|
154
|
+
(entry) =>
|
|
155
|
+
entry.type === "custom" && entry.customType === ROUTING_ENTRY && entry.data?.eventId === prior.eventId,
|
|
156
|
+
);
|
|
157
|
+
if (
|
|
158
|
+
this.observed.get(key)?.value !== body ||
|
|
159
|
+
this.observed.get(key)?.eventId !== prior.eventId ||
|
|
160
|
+
this.observed.get(key)?.entryId !== occurrence?.id
|
|
161
|
+
) {
|
|
162
|
+
this.block("prior_acknowledgment_unclassified");
|
|
163
|
+
throw new RoutingError("acknowledgment_uncertain", this.blocked);
|
|
164
|
+
}
|
|
165
|
+
return prior;
|
|
166
|
+
}
|
|
167
|
+
// Validate the whole state transition before mutating Pi's session memory.
|
|
168
|
+
const candidate = reduce(this.state(), value);
|
|
169
|
+
const baseline = this.cachedEntries;
|
|
170
|
+
this.attempted.set(key, value);
|
|
171
|
+
try {
|
|
172
|
+
this.pi.appendEntry(ROUTING_ENTRY, value);
|
|
173
|
+
const found = this.reader
|
|
174
|
+
.getBranch()
|
|
175
|
+
.find((e) => e.type === "custom" && e.customType === ROUTING_ENTRY && e.data?.eventId === value.eventId);
|
|
176
|
+
check(found && eventValue(parseRoutingEvent(found.data)) === body, "append_not_observed");
|
|
177
|
+
this.observed.set(key, { value: body, eventId: value.eventId, entryId: found.id });
|
|
178
|
+
this.attempted.delete(key);
|
|
179
|
+
const branch = this.reader.getBranch();
|
|
180
|
+
const delta = branch.slice(baseline.length).filter((e) => e.type === "custom" && e.customType === ROUTING_ENTRY);
|
|
181
|
+
if (baseline.every((e, i) => branch[i] === e) && delta.length === 1 && delta[0].id === found.id) {
|
|
182
|
+
// Publish the already validated candidate only after acknowledged native append.
|
|
183
|
+
this.cachedState = candidate;
|
|
184
|
+
this.cachedEntries = [...branch];
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.block("append_acknowledgment_uncertain");
|
|
189
|
+
throw new RoutingError(
|
|
190
|
+
"acknowledgment_uncertain",
|
|
191
|
+
error instanceof Error ? error.message : "Pi append/readback did not acknowledge the event.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { open, stat } from "node:fs/promises";
|
|
3
|
+
import { TextDecoder } from "node:util";
|
|
4
|
+
export class ReadOnlySessionError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
line;
|
|
7
|
+
constructor(code, message, line) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.line = line;
|
|
11
|
+
this.name = "ReadOnlySessionError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const SESSION_READ_LIMITS = {
|
|
15
|
+
maxBytes: 512 * 1024 * 1024,
|
|
16
|
+
maxEntryBytes: 128 * 1024 * 1024,
|
|
17
|
+
maxEntries: 250000,
|
|
18
|
+
};
|
|
19
|
+
function decodeEntry(text, line, seen) {
|
|
20
|
+
let value;
|
|
21
|
+
try {
|
|
22
|
+
value = JSON.parse(text);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new ReadOnlySessionError("invalid_json", "Incomplete or invalid session JSON.", line);
|
|
25
|
+
}
|
|
26
|
+
if (!value || Array.isArray(value) || typeof value !== "object" || typeof value.type !== "string")
|
|
27
|
+
throw new ReadOnlySessionError("invalid_entry", "Invalid session entry.", line);
|
|
28
|
+
if (line === 1) {
|
|
29
|
+
if (
|
|
30
|
+
value.type !== "session" ||
|
|
31
|
+
typeof value.id !== "string" ||
|
|
32
|
+
!value.id ||
|
|
33
|
+
![2, 3].includes(value.version) ||
|
|
34
|
+
typeof value.cwd !== "string"
|
|
35
|
+
)
|
|
36
|
+
throw new ReadOnlySessionError("invalid_header", "A supported native session header is required.", line);
|
|
37
|
+
} else {
|
|
38
|
+
if (value.type === "session" || typeof value.id !== "string" || !value.id || seen.has(value.id))
|
|
39
|
+
throw new ReadOnlySessionError("invalid_id", "Missing, duplicate, or misplaced session identity.", line);
|
|
40
|
+
if (value.parentId !== null && (typeof value.parentId !== "string" || !seen.has(value.parentId)))
|
|
41
|
+
throw new ReadOnlySessionError("invalid_parent", "Parent is absent or occurs after its child.", line);
|
|
42
|
+
seen.add(value.id);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
export function parseReadOnlySessionText(text) {
|
|
47
|
+
const lines = text.split("\n");
|
|
48
|
+
if (lines.at(-1) === "") lines.pop();
|
|
49
|
+
if (!lines.length || !lines[0]) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
return lines.map((line, i) => decodeEntry(line, i + 1, seen));
|
|
52
|
+
}
|
|
53
|
+
export async function readOnlySessionSnapshot(path, options = {}) {
|
|
54
|
+
const limits = { ...SESSION_READ_LIMITS, ...options };
|
|
55
|
+
if (Object.values(limits).some((n) => !Number.isSafeInteger(n) || n <= 0))
|
|
56
|
+
throw new ReadOnlySessionError("invalid_limit", "Invalid snapshot limit.");
|
|
57
|
+
let file;
|
|
58
|
+
try {
|
|
59
|
+
file = await open(path, "r");
|
|
60
|
+
const before = await file.stat({ bigint: true });
|
|
61
|
+
if (!before.isFile()) throw new ReadOnlySessionError("read_limit", "Session is not a regular file.");
|
|
62
|
+
if (before.size > BigInt(limits.maxBytes))
|
|
63
|
+
throw new ReadOnlySessionError(
|
|
64
|
+
"read_limit",
|
|
65
|
+
`Session exceeds the supported ${limits.maxBytes} byte snapshot limit.`,
|
|
66
|
+
);
|
|
67
|
+
const hash = createHash("sha256"),
|
|
68
|
+
entries = [],
|
|
69
|
+
seen = new Set();
|
|
70
|
+
let total = 0,
|
|
71
|
+
lineBytes = 0,
|
|
72
|
+
parts = [];
|
|
73
|
+
const append = (part) => {
|
|
74
|
+
lineBytes += part.length;
|
|
75
|
+
if (lineBytes > limits.maxEntryBytes)
|
|
76
|
+
throw new ReadOnlySessionError(
|
|
77
|
+
"entry_limit",
|
|
78
|
+
`Session entry exceeds ${limits.maxEntryBytes} bytes.`,
|
|
79
|
+
entries.length + 1,
|
|
80
|
+
);
|
|
81
|
+
if (part.length) parts.push(part);
|
|
82
|
+
};
|
|
83
|
+
const decode = () => {
|
|
84
|
+
if (entries.length >= limits.maxEntries)
|
|
85
|
+
throw new ReadOnlySessionError("entry_count_limit", `Session exceeds ${limits.maxEntries} entries.`);
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, lineBytes));
|
|
89
|
+
} catch {
|
|
90
|
+
throw new ReadOnlySessionError("invalid_encoding", "Session is not complete UTF-8.", entries.length + 1);
|
|
91
|
+
}
|
|
92
|
+
entries.push(decodeEntry(text, entries.length + 1, seen));
|
|
93
|
+
parts = [];
|
|
94
|
+
lineBytes = 0;
|
|
95
|
+
};
|
|
96
|
+
for (;;) {
|
|
97
|
+
const buffer = Buffer.alloc(64 * 1024),
|
|
98
|
+
{ bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
99
|
+
if (!bytesRead) break;
|
|
100
|
+
total += bytesRead;
|
|
101
|
+
if (total > limits.maxBytes)
|
|
102
|
+
throw new ReadOnlySessionError("read_limit", "Session grew beyond the supported snapshot limit.");
|
|
103
|
+
const bytes = buffer.subarray(0, bytesRead);
|
|
104
|
+
hash.update(bytes);
|
|
105
|
+
let start = 0,
|
|
106
|
+
end;
|
|
107
|
+
while ((end = bytes.indexOf(10, start)) !== -1) {
|
|
108
|
+
append(bytes.subarray(start, end));
|
|
109
|
+
decode();
|
|
110
|
+
start = end + 1;
|
|
111
|
+
}
|
|
112
|
+
append(bytes.subarray(start));
|
|
113
|
+
}
|
|
114
|
+
if (lineBytes) decode();
|
|
115
|
+
if (!entries.length) throw new ReadOnlySessionError("empty", "Session snapshot is empty.");
|
|
116
|
+
const after = await file.stat({ bigint: true }),
|
|
117
|
+
current = await stat(path, { bigint: true });
|
|
118
|
+
const signature = (v) => [v.dev, v.ino, v.size, v.mtimeNs, v.ctimeNs].join(":");
|
|
119
|
+
if (
|
|
120
|
+
signature(before) !== signature(after) ||
|
|
121
|
+
signature(after) !== signature(current) ||
|
|
122
|
+
after.size !== BigInt(total)
|
|
123
|
+
)
|
|
124
|
+
throw new ReadOnlySessionError("source_changed", "Session changed during snapshot capture.");
|
|
125
|
+
return { path, sessionId: entries[0].id, version: entries[0].version, hash: hash.digest("hex"), entries };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (error instanceof ReadOnlySessionError) throw error;
|
|
128
|
+
throw new ReadOnlySessionError("read_failed", error instanceof Error ? error.message : String(error));
|
|
129
|
+
} finally {
|
|
130
|
+
await file?.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function activeReadOnlySessionBranch(snapshot, leafId) {
|
|
134
|
+
if (leafId === null) return [];
|
|
135
|
+
const byId = new Map(snapshot.entries.slice(1).map((entry) => [entry.id, entry]));
|
|
136
|
+
const path = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
let cursor = leafId;
|
|
139
|
+
while (cursor !== null) {
|
|
140
|
+
const entry = byId.get(cursor);
|
|
141
|
+
if (!entry || seen.has(cursor))
|
|
142
|
+
throw new ReadOnlySessionError("ancestry_unavailable", "Requested native ancestry is unavailable.");
|
|
143
|
+
seen.add(cursor);
|
|
144
|
+
path.push(entry);
|
|
145
|
+
cursor = entry.parentId;
|
|
146
|
+
}
|
|
147
|
+
return path.reverse();
|
|
148
|
+
}
|