@earendil-works/pi-agent-core 0.85.1 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +64 -22
- package/dist/agent-loop.js.map +1 -1
- package/dist/agent.d.ts +5 -3
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +15 -6
- package/dist/agent.js.map +1 -1
- package/dist/harness/config.d.ts +1 -1
- package/dist/harness/config.d.ts.map +1 -1
- package/dist/harness/config.js +10 -2
- package/dist/harness/config.js.map +1 -1
- package/dist/harness/env/nodejs.d.ts +2 -1
- package/dist/harness/env/nodejs.d.ts.map +1 -1
- package/dist/harness/env/nodejs.js +98 -27
- package/dist/harness/env/nodejs.js.map +1 -1
- package/dist/harness/execution/tools.d.ts +1 -1
- package/dist/harness/execution/tools.d.ts.map +1 -1
- package/dist/harness/execution/tools.js +0 -2
- package/dist/harness/execution/tools.js.map +1 -1
- package/dist/harness/messages.d.ts.map +1 -1
- package/dist/harness/messages.js +1 -0
- package/dist/harness/messages.js.map +1 -1
- package/dist/harness/pico3/bash.d.ts +10 -0
- package/dist/harness/pico3/bash.d.ts.map +1 -0
- package/dist/harness/pico3/bash.js +25 -0
- package/dist/harness/pico3/bash.js.map +1 -0
- package/dist/harness/pico3/bounded.d.ts +21 -0
- package/dist/harness/pico3/bounded.d.ts.map +1 -0
- package/dist/harness/pico3/bounded.js +98 -0
- package/dist/harness/pico3/bounded.js.map +1 -0
- package/dist/harness/pico3/chord.d.ts +41 -0
- package/dist/harness/pico3/chord.d.ts.map +1 -0
- package/dist/harness/pico3/chord.js +130 -0
- package/dist/harness/pico3/chord.js.map +1 -0
- package/dist/harness/pico3/context.d.ts +16 -0
- package/dist/harness/pico3/context.d.ts.map +1 -0
- package/dist/harness/pico3/context.js +88 -0
- package/dist/harness/pico3/context.js.map +1 -0
- package/dist/harness/pico3/harness.d.ts +197 -0
- package/dist/harness/pico3/harness.d.ts.map +1 -0
- package/dist/harness/pico3/harness.js +654 -0
- package/dist/harness/pico3/harness.js.map +1 -0
- package/dist/harness/pico3/hooks.d.ts +10 -0
- package/dist/harness/pico3/hooks.d.ts.map +1 -0
- package/dist/harness/pico3/hooks.js +33 -0
- package/dist/harness/pico3/hooks.js.map +1 -0
- package/dist/harness/pico3/index.d.ts +19 -0
- package/dist/harness/pico3/index.d.ts.map +1 -0
- package/dist/harness/pico3/index.js +15 -0
- package/dist/harness/pico3/index.js.map +1 -0
- package/dist/harness/pico3/jsonl.d.ts +58 -0
- package/dist/harness/pico3/jsonl.d.ts.map +1 -0
- package/dist/harness/pico3/jsonl.js +325 -0
- package/dist/harness/pico3/jsonl.js.map +1 -0
- package/dist/harness/pico3/kinds/collapse.d.ts +49 -0
- package/dist/harness/pico3/kinds/collapse.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/collapse.js +191 -0
- package/dist/harness/pico3/kinds/collapse.js.map +1 -0
- package/dist/harness/pico3/kinds/entries.d.ts +78 -0
- package/dist/harness/pico3/kinds/entries.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/entries.js +13 -0
- package/dist/harness/pico3/kinds/entries.js.map +1 -0
- package/dist/harness/pico3/kinds/frames.d.ts +9 -0
- package/dist/harness/pico3/kinds/frames.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/frames.js +75 -0
- package/dist/harness/pico3/kinds/frames.js.map +1 -0
- package/dist/harness/pico3/kinds/generation.d.ts +94 -0
- package/dist/harness/pico3/kinds/generation.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/generation.js +504 -0
- package/dist/harness/pico3/kinds/generation.js.map +1 -0
- package/dist/harness/pico3/kinds/job.d.ts +44 -0
- package/dist/harness/pico3/kinds/job.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/job.js +143 -0
- package/dist/harness/pico3/kinds/job.js.map +1 -0
- package/dist/harness/pico3/kinds/plugin.d.ts +15 -0
- package/dist/harness/pico3/kinds/plugin.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/plugin.js +35 -0
- package/dist/harness/pico3/kinds/plugin.js.map +1 -0
- package/dist/harness/pico3/kinds/post-tools.d.ts +22 -0
- package/dist/harness/pico3/kinds/post-tools.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/post-tools.js +145 -0
- package/dist/harness/pico3/kinds/post-tools.js.map +1 -0
- package/dist/harness/pico3/kinds/task-api.d.ts +3 -0
- package/dist/harness/pico3/kinds/task-api.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/task-api.js +45 -0
- package/dist/harness/pico3/kinds/task-api.js.map +1 -0
- package/dist/harness/pico3/kinds/tool.d.ts +43 -0
- package/dist/harness/pico3/kinds/tool.d.ts.map +1 -0
- package/dist/harness/pico3/kinds/tool.js +373 -0
- package/dist/harness/pico3/kinds/tool.js.map +1 -0
- package/dist/harness/pico3/membrane.d.ts +23 -0
- package/dist/harness/pico3/membrane.d.ts.map +1 -0
- package/dist/harness/pico3/membrane.js +141 -0
- package/dist/harness/pico3/membrane.js.map +1 -0
- package/dist/harness/pico3/memory.d.ts +53 -0
- package/dist/harness/pico3/memory.d.ts.map +1 -0
- package/dist/harness/pico3/memory.js +264 -0
- package/dist/harness/pico3/memory.js.map +1 -0
- package/dist/harness/pico3/scheduler.d.ts +46 -0
- package/dist/harness/pico3/scheduler.d.ts.map +1 -0
- package/dist/harness/pico3/scheduler.js +438 -0
- package/dist/harness/pico3/scheduler.js.map +1 -0
- package/dist/harness/pico3/session.d.ts +263 -0
- package/dist/harness/pico3/session.d.ts.map +1 -0
- package/dist/harness/pico3/session.js +1347 -0
- package/dist/harness/pico3/session.js.map +1 -0
- package/dist/harness/pico3/system.d.ts +126 -0
- package/dist/harness/pico3/system.d.ts.map +1 -0
- package/dist/harness/pico3/system.js +244 -0
- package/dist/harness/pico3/system.js.map +1 -0
- package/dist/harness/pico3/types.d.ts +973 -0
- package/dist/harness/pico3/types.d.ts.map +1 -0
- package/dist/harness/pico3/types.js +96 -0
- package/dist/harness/pico3/types.js.map +1 -0
- package/dist/harness/pico3/view.d.ts +34 -0
- package/dist/harness/pico3/view.d.ts.map +1 -0
- package/dist/harness/pico3/view.js +417 -0
- package/dist/harness/pico3/view.js.map +1 -0
- package/dist/harness/runtime/drive/boundary.d.ts.map +1 -1
- package/dist/harness/runtime/drive/boundary.js +6 -3
- package/dist/harness/runtime/drive/boundary.js.map +1 -1
- package/dist/harness/runtime/drive/response.d.ts.map +1 -1
- package/dist/harness/runtime/drive/response.js +4 -4
- package/dist/harness/runtime/drive/response.js.map +1 -1
- package/dist/harness/runtime/drive/retry.d.ts +2 -2
- package/dist/harness/runtime/drive/retry.d.ts.map +1 -1
- package/dist/harness/runtime/drive/retry.js +3 -6
- package/dist/harness/runtime/drive/retry.js.map +1 -1
- package/dist/harness/runtime/drive/structural.d.ts.map +1 -1
- package/dist/harness/runtime/drive/structural.js +5 -5
- package/dist/harness/runtime/drive/structural.js.map +1 -1
- package/dist/harness/runtime/drive/tool-placement.d.ts.map +1 -1
- package/dist/harness/runtime/drive/tool-placement.js +2 -27
- package/dist/harness/runtime/drive/tool-placement.js.map +1 -1
- package/dist/harness/runtime/drive/tools.d.ts.map +1 -1
- package/dist/harness/runtime/drive/tools.js.map +1 -1
- package/dist/harness/session/fork-policy.d.ts +20 -12
- package/dist/harness/session/fork-policy.d.ts.map +1 -1
- package/dist/harness/session/fork-policy.js +45 -12
- package/dist/harness/session/fork-policy.js.map +1 -1
- package/dist/harness/session/fork.d.ts +0 -2
- package/dist/harness/session/fork.d.ts.map +1 -1
- package/dist/harness/session/fork.js +20 -68
- package/dist/harness/session/fork.js.map +1 -1
- package/dist/harness/session/in-memory-storage-state.d.ts +5 -5
- package/dist/harness/session/in-memory-storage-state.d.ts.map +1 -1
- package/dist/harness/session/in-memory-storage-state.js +99 -39
- package/dist/harness/session/in-memory-storage-state.js.map +1 -1
- package/dist/harness/session/index.d.ts +1 -1
- package/dist/harness/session/index.d.ts.map +1 -1
- package/dist/harness/session/index.js +1 -1
- package/dist/harness/session/index.js.map +1 -1
- package/dist/harness/session/jsonl/fork.d.ts +38 -0
- package/dist/harness/session/jsonl/fork.d.ts.map +1 -0
- package/dist/harness/session/jsonl/fork.js +264 -0
- package/dist/harness/session/jsonl/fork.js.map +1 -0
- package/dist/harness/session/jsonl/io.d.ts +14 -0
- package/dist/harness/session/jsonl/io.d.ts.map +1 -0
- package/dist/harness/session/jsonl/io.js +86 -0
- package/dist/harness/session/jsonl/io.js.map +1 -0
- package/dist/harness/session/jsonl/legacy-v3.d.ts +33 -8
- package/dist/harness/session/jsonl/legacy-v3.d.ts.map +1 -1
- package/dist/harness/session/jsonl/legacy-v3.js +285 -206
- package/dist/harness/session/jsonl/legacy-v3.js.map +1 -1
- package/dist/harness/session/jsonl/repo.d.ts +2 -1
- package/dist/harness/session/jsonl/repo.d.ts.map +1 -1
- package/dist/harness/session/jsonl/repo.js +36 -20
- package/dist/harness/session/jsonl/repo.js.map +1 -1
- package/dist/harness/session/jsonl/storage.d.ts +4 -5
- package/dist/harness/session/jsonl/storage.d.ts.map +1 -1
- package/dist/harness/session/jsonl/storage.js +39 -113
- package/dist/harness/session/jsonl/storage.js.map +1 -1
- package/dist/harness/session/memory.d.ts +3 -5
- package/dist/harness/session/memory.d.ts.map +1 -1
- package/dist/harness/session/memory.js +9 -14
- package/dist/harness/session/memory.js.map +1 -1
- package/dist/harness/session/testing/conformance/session-repo.d.ts +2 -0
- package/dist/harness/session/testing/conformance/session-repo.d.ts.map +1 -1
- package/dist/harness/session/testing/conformance/session-repo.js +220 -1
- package/dist/harness/session/testing/conformance/session-repo.js.map +1 -1
- package/dist/harness/session/testing/index.d.ts +1 -1
- package/dist/harness/session/testing/index.d.ts.map +1 -1
- package/dist/harness/session/testing/index.js +1 -1
- package/dist/harness/session/testing/index.js.map +1 -1
- package/dist/harness/session/types.d.ts +1 -0
- package/dist/harness/session/types.d.ts.map +1 -1
- package/dist/harness/session/types.js.map +1 -1
- package/dist/harness/types.d.ts +14 -0
- package/dist/harness/types.d.ts.map +1 -1
- package/dist/harness/types.js.map +1 -1
- package/dist/proxy.d.ts +2 -2
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js.map +1 -1
- package/dist/types.d.ts +30 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +10 -6
|
@@ -0,0 +1,1347 @@
|
|
|
1
|
+
import { isJsonValue } from "@earendil-works/chord";
|
|
2
|
+
import { createContextKey, withContextValue, withoutAbortSignal } from "@earendil-works/chord/context";
|
|
3
|
+
import { isBase, track } from "@earendil-works/chord/delta";
|
|
4
|
+
import { deriveContext } from "./context.js";
|
|
5
|
+
import { Membrane } from "./membrane.js";
|
|
6
|
+
import { Closed, CollapseInProgress, ConversationBusy, Faulted, Forbidden, GenerationInProgress, ReadAfterWrite, } from "./types.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Document cache and defaults
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const docKey = (ref) => (ref.doc === "session" ? "session" : `${ref.doc}:${ref.conversationId}`);
|
|
11
|
+
/** Remove matching elements from a tracked array IN PLACE (never `arr = arr.filter(...)` on a tracked doc). */
|
|
12
|
+
export function removeWhere(arr, pred) {
|
|
13
|
+
for (let i = arr.length - 1; i >= 0; i--)
|
|
14
|
+
if (pred(arr[i]))
|
|
15
|
+
arr.splice(i, 1);
|
|
16
|
+
}
|
|
17
|
+
const CORE_KINDS = new Set(["pi.generation", "pi.tool", "pi.post_tools", "pi.collapse"]);
|
|
18
|
+
const CORE_CONFIG_VALIDATORS = {
|
|
19
|
+
model: (value) => exactObject(value, ["provider", "modelId"]) && value.provider !== "" && value.modelId !== "",
|
|
20
|
+
thinkingLevel: (value) => typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value),
|
|
21
|
+
selectedTools: (value) => Array.isArray(value) && value.every((name) => typeof name === "string"),
|
|
22
|
+
profile: (value) => typeof value === "string",
|
|
23
|
+
retry: (value) => exactObject(value, ["enabled", "maxRetries", "baseDelayMs"], ["maxAgentDelayMs"]) &&
|
|
24
|
+
typeof value.enabled === "boolean" &&
|
|
25
|
+
typeof value.maxRetries === "number" &&
|
|
26
|
+
Number.isSafeInteger(value.maxRetries) &&
|
|
27
|
+
value.maxRetries >= 0 &&
|
|
28
|
+
finiteNonnegative(value.baseDelayMs) &&
|
|
29
|
+
(value.maxAgentDelayMs === undefined || finiteNonnegative(value.maxAgentDelayMs)),
|
|
30
|
+
threshold: (value) => typeof value === "number" && Number.isFinite(value),
|
|
31
|
+
keepRecent: finiteNonnegative,
|
|
32
|
+
steeringMode: (value) => value === "all" || value === "one-at-a-time",
|
|
33
|
+
followUpMode: (value) => value === "all" || value === "one-at-a-time",
|
|
34
|
+
};
|
|
35
|
+
function finiteNonnegative(value) {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
37
|
+
}
|
|
38
|
+
function exactObject(value, required, optional = []) {
|
|
39
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
40
|
+
return false;
|
|
41
|
+
const keys = Object.keys(value);
|
|
42
|
+
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
43
|
+
keys.every((key) => required.includes(key) || optional.includes(key)));
|
|
44
|
+
}
|
|
45
|
+
export const isCoreKind = (name) => CORE_KINDS.has(name);
|
|
46
|
+
/** Declared config defaults, derived from the registered kinds. Nothing is duplicated elsewhere. */
|
|
47
|
+
export class Defaults {
|
|
48
|
+
rewindable = {};
|
|
49
|
+
sticky = {};
|
|
50
|
+
route = new Map();
|
|
51
|
+
owners = new Map();
|
|
52
|
+
constructor(kinds) {
|
|
53
|
+
for (const kind of kinds)
|
|
54
|
+
this.register(kind);
|
|
55
|
+
}
|
|
56
|
+
register(kind) {
|
|
57
|
+
const declarations = ["rewindable", "sticky"].flatMap((doc) => Object.entries(kind.config?.[doc] ?? {}).map(([key, value]) => ({ doc, key, value })));
|
|
58
|
+
const local = new Set();
|
|
59
|
+
for (const { key } of declarations) {
|
|
60
|
+
if (local.has(key) || this.route.has(key))
|
|
61
|
+
throw new Error(`config key "${key}" declared by more than one kind`);
|
|
62
|
+
local.add(key);
|
|
63
|
+
}
|
|
64
|
+
for (const { doc, key, value } of declarations) {
|
|
65
|
+
this.route.set(key, doc);
|
|
66
|
+
this.owners.set(key, kind);
|
|
67
|
+
if (value !== undefined)
|
|
68
|
+
this[doc][key] = structuredClone(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
unregister(kind) {
|
|
72
|
+
for (const [key, owner] of this.owners) {
|
|
73
|
+
if (owner !== kind)
|
|
74
|
+
continue;
|
|
75
|
+
this.owners.delete(key);
|
|
76
|
+
const doc = this.route.get(key);
|
|
77
|
+
this.route.delete(key);
|
|
78
|
+
if (doc !== undefined)
|
|
79
|
+
delete this[doc][key];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
validate(key, value) {
|
|
83
|
+
if (!isJsonValue(value))
|
|
84
|
+
return false;
|
|
85
|
+
const core = CORE_CONFIG_VALIDATORS[key];
|
|
86
|
+
if (core !== undefined)
|
|
87
|
+
return core(value);
|
|
88
|
+
return this.route.has(key);
|
|
89
|
+
}
|
|
90
|
+
validateSeed(doc, seed) {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const [key, value] of Object.entries(seed)) {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
if (this.route.get(key) !== doc || !this.validate(key, value))
|
|
96
|
+
throw new TypeError(`invalid ${doc} config value for "${key}"`);
|
|
97
|
+
out[key] = structuredClone(value);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Fill declared keys that are absent (never `??`: a stored null is a value). */
|
|
102
|
+
fill(doc, target) {
|
|
103
|
+
for (const [key, value] of Object.entries(this[doc]))
|
|
104
|
+
if (!(key in target))
|
|
105
|
+
target[key] = structuredClone(value);
|
|
106
|
+
}
|
|
107
|
+
freshRewindable(over = {}, preservePlugins = false) {
|
|
108
|
+
const base = { plugins: {} };
|
|
109
|
+
this.fill("rewindable", base);
|
|
110
|
+
const { plugins, ...raw } = over;
|
|
111
|
+
const rest = preservePlugins ? raw : this.validateSeed("rewindable", raw);
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
...rest,
|
|
115
|
+
...(preservePlugins && plugins !== undefined ? { plugins: structuredClone(plugins) } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
freshSticky(over = {}) {
|
|
119
|
+
const base = { inbox: [], turn: { tools: [] }, tasks: {}, plugins: {} };
|
|
120
|
+
this.fill("sticky", base);
|
|
121
|
+
const { inbox: _inbox, turn: _turn, tasks: _tasks, plugins: _plugins, ...raw } = over;
|
|
122
|
+
return { ...base, ...this.validateSeed("sticky", raw) };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
class Docs {
|
|
126
|
+
trackers = new Map();
|
|
127
|
+
storage;
|
|
128
|
+
defaults;
|
|
129
|
+
/** Bytes of ops written since the last base, per document. Drives rebase+truncate. */
|
|
130
|
+
sinceBase = new Map();
|
|
131
|
+
constructor(storage, defaults) {
|
|
132
|
+
this.storage = storage;
|
|
133
|
+
this.defaults = defaults;
|
|
134
|
+
}
|
|
135
|
+
noteOps(ref, ops) {
|
|
136
|
+
const key = docKey(ref);
|
|
137
|
+
this.sinceBase.set(key, isBase(ops) ? 0 : (this.sinceBase.get(key) ?? 0) + JSON.stringify(ops).length);
|
|
138
|
+
}
|
|
139
|
+
requestBase(ref) {
|
|
140
|
+
this.trackers.get(docKey(ref))?.rebase();
|
|
141
|
+
}
|
|
142
|
+
async get(ref, ctx) {
|
|
143
|
+
const key = docKey(ref);
|
|
144
|
+
const cached = this.trackers.get(key);
|
|
145
|
+
if (cached !== undefined)
|
|
146
|
+
return cached;
|
|
147
|
+
const stored = await this.storage.doc(ref, ctx);
|
|
148
|
+
if (stored === undefined)
|
|
149
|
+
throw new Error(`document ${key} does not exist`);
|
|
150
|
+
if (ref.doc !== "session")
|
|
151
|
+
this.defaults.fill(ref.doc, stored); // declared defaults are visible without being persisted
|
|
152
|
+
const t = track(stored);
|
|
153
|
+
t.flush(); // consume the synthetic first flush; never persisted
|
|
154
|
+
this.trackers.set(key, t);
|
|
155
|
+
return t;
|
|
156
|
+
}
|
|
157
|
+
evict(ref) {
|
|
158
|
+
this.trackers.delete(docKey(ref));
|
|
159
|
+
}
|
|
160
|
+
peek(ref) {
|
|
161
|
+
return this.trackers.get(docKey(ref));
|
|
162
|
+
}
|
|
163
|
+
loaded(ref) {
|
|
164
|
+
return this.trackers.get(docKey(ref))?.target;
|
|
165
|
+
}
|
|
166
|
+
adopt(ref, tracker) {
|
|
167
|
+
this.trackers.set(docKey(ref), tracker);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const HOST_TX_METHODS = [
|
|
171
|
+
"conversation",
|
|
172
|
+
"entry",
|
|
173
|
+
"entries",
|
|
174
|
+
"newestEntry",
|
|
175
|
+
"scanEntries",
|
|
176
|
+
"context",
|
|
177
|
+
"task",
|
|
178
|
+
"tasks",
|
|
179
|
+
"input",
|
|
180
|
+
"rewindableAsOf",
|
|
181
|
+
"snapshot",
|
|
182
|
+
"plugins",
|
|
183
|
+
"emit",
|
|
184
|
+
"config",
|
|
185
|
+
"write",
|
|
186
|
+
"createTask",
|
|
187
|
+
"createConversation",
|
|
188
|
+
];
|
|
189
|
+
const TASK_TX_METHODS = ["checkpoint", "slot"];
|
|
190
|
+
const CORE_TX_METHODS = [
|
|
191
|
+
"rewindable",
|
|
192
|
+
"sticky",
|
|
193
|
+
"session",
|
|
194
|
+
"toolSlot",
|
|
195
|
+
"appendEntry",
|
|
196
|
+
"send",
|
|
197
|
+
"resolveInputs",
|
|
198
|
+
"boundary",
|
|
199
|
+
"markTask",
|
|
200
|
+
"setTask",
|
|
201
|
+
"withdrawInput",
|
|
202
|
+
"createOwnedConversation",
|
|
203
|
+
"createForkConversation",
|
|
204
|
+
];
|
|
205
|
+
const CALLBACK_TX_METHODS = new Set([...HOST_TX_METHODS, ...TASK_TX_METHODS, ...CORE_TX_METHODS]);
|
|
206
|
+
class TxImpl {
|
|
207
|
+
writes = [];
|
|
208
|
+
touched = new Map();
|
|
209
|
+
membrane = new Membrane("tx");
|
|
210
|
+
// Overlays for direct reads: complete for the tables a closure reads after writing.
|
|
211
|
+
createdTasks = new Map();
|
|
212
|
+
createdEntries = new Map();
|
|
213
|
+
createdConversations = new Map();
|
|
214
|
+
inputsById = new Map();
|
|
215
|
+
inputsByRequest = new Map();
|
|
216
|
+
/**
|
|
217
|
+
* Transaction-local identity cache for heterogeneous namespace façades.
|
|
218
|
+
* Keys are current namespace token objects, validated before lookup; values are
|
|
219
|
+
* merged membrane wrappers. TxImpl owns the map and revoke() invalidates every value.
|
|
220
|
+
*/
|
|
221
|
+
namespaceViews = new Map();
|
|
222
|
+
changedConfig = new Map();
|
|
223
|
+
// Domains written: scans over them reject.
|
|
224
|
+
wroteEntries = new Set();
|
|
225
|
+
wroteTasks = false;
|
|
226
|
+
poisoned;
|
|
227
|
+
surfaceActive = true;
|
|
228
|
+
/** Set while a terminal closure runs: the invoker's task counts as gone for busy(). */
|
|
229
|
+
closing = false;
|
|
230
|
+
changes = { entries: [], tasks: [], inputs: [], conversations: [], docs: [], events: [] };
|
|
231
|
+
invoker;
|
|
232
|
+
storage;
|
|
233
|
+
docs;
|
|
234
|
+
defaults;
|
|
235
|
+
kinds;
|
|
236
|
+
namespaces;
|
|
237
|
+
liveTasks;
|
|
238
|
+
conversations;
|
|
239
|
+
now;
|
|
240
|
+
ctx;
|
|
241
|
+
constructor(invoker, storage, docs, defaults, kinds, namespaces, liveTasks, conversations, now, ctx) {
|
|
242
|
+
this.invoker = invoker;
|
|
243
|
+
this.storage = storage;
|
|
244
|
+
this.docs = docs;
|
|
245
|
+
this.defaults = defaults;
|
|
246
|
+
this.kinds = kinds;
|
|
247
|
+
this.namespaces = namespaces;
|
|
248
|
+
this.liveTasks = liveTasks;
|
|
249
|
+
this.conversations = conversations;
|
|
250
|
+
this.now = now;
|
|
251
|
+
this.ctx = ctx;
|
|
252
|
+
}
|
|
253
|
+
callbackSurface() {
|
|
254
|
+
const implementation = this;
|
|
255
|
+
const methods = new Map();
|
|
256
|
+
const target = Object.freeze(Object.create(null));
|
|
257
|
+
return new Proxy(target, {
|
|
258
|
+
get: (_target, key) => {
|
|
259
|
+
if (typeof key !== "string" || !CALLBACK_TX_METHODS.has(key))
|
|
260
|
+
return undefined;
|
|
261
|
+
const cached = methods.get(key);
|
|
262
|
+
if (cached !== undefined)
|
|
263
|
+
return cached;
|
|
264
|
+
const method = implementation[key];
|
|
265
|
+
if (typeof method !== "function")
|
|
266
|
+
throw new Error(`transaction method ${key} is missing`);
|
|
267
|
+
const bound = (...args) => {
|
|
268
|
+
this.assertSurfaceActive();
|
|
269
|
+
return Reflect.apply(method, this, args);
|
|
270
|
+
};
|
|
271
|
+
methods.set(key, bound);
|
|
272
|
+
return bound;
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
closeSurface() {
|
|
277
|
+
this.surfaceActive = false;
|
|
278
|
+
}
|
|
279
|
+
assertSurfaceActive() {
|
|
280
|
+
if (!this.surfaceActive)
|
|
281
|
+
throw new TypeError("transaction used outside its callback");
|
|
282
|
+
}
|
|
283
|
+
// --- capability & scope -------------------------------------------------
|
|
284
|
+
get core() {
|
|
285
|
+
return this.invoker.type === "kernel" || (this.invoker.type === "task" && this.invoker.core);
|
|
286
|
+
}
|
|
287
|
+
assertCore(what) {
|
|
288
|
+
if (!this.core)
|
|
289
|
+
throw new Forbidden(`${what}: core turn machinery only`);
|
|
290
|
+
}
|
|
291
|
+
assertNotHost(what) {
|
|
292
|
+
if (this.invoker.type !== "task")
|
|
293
|
+
throw new Forbidden(`${what} outside a task`);
|
|
294
|
+
}
|
|
295
|
+
/** A task may touch its own conversation and the subtree it owns. The host and core may touch anything. */
|
|
296
|
+
inScope(conversationId) {
|
|
297
|
+
if (this.invoker.type !== "task" || this.invoker.core)
|
|
298
|
+
return true;
|
|
299
|
+
if (conversationId === this.invoker.conversationId || this.createdConversations.has(conversationId))
|
|
300
|
+
return true;
|
|
301
|
+
const task = this.createdTasks.get(this.invoker.id) ?? this.liveTasks.get(this.invoker.id);
|
|
302
|
+
for (const root of task?.owns ?? [])
|
|
303
|
+
if (this.conversations.subtree(root).has(conversationId))
|
|
304
|
+
return true;
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
assertScope(conversationId, what) {
|
|
308
|
+
if (!this.inScope(conversationId))
|
|
309
|
+
throw new Forbidden(`${what}: conversation ${conversationId} is outside this task's subtree`);
|
|
310
|
+
}
|
|
311
|
+
assertEntryScope(entry, what) {
|
|
312
|
+
if (this.inScope(entry.conversationId))
|
|
313
|
+
return;
|
|
314
|
+
if (this.invoker.type !== "task" || this.invoker.core)
|
|
315
|
+
throw new Forbidden(`${what}: entry ${entry.id} is outside this task's subtree`);
|
|
316
|
+
const candidates = new Set([this.invoker.conversationId, ...this.createdConversations.keys()]);
|
|
317
|
+
const task = this.createdTasks.get(this.invoker.id) ?? this.liveTasks.get(this.invoker.id);
|
|
318
|
+
for (const root of task?.owns ?? [])
|
|
319
|
+
for (const id of this.conversations.subtree(root))
|
|
320
|
+
candidates.add(id);
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
let conversation = this.createdConversations.get(candidate) ?? this.conversations.get(candidate);
|
|
323
|
+
while (conversation?.parent !== undefined) {
|
|
324
|
+
if (conversation.parent.conversationId === entry.conversationId && entry.id <= conversation.parent.at)
|
|
325
|
+
return;
|
|
326
|
+
conversation = this.conversations.get(conversation.parent.conversationId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw new Forbidden(`${what}: entry ${entry.id} is not visible from this task's subtree`);
|
|
330
|
+
}
|
|
331
|
+
poison(error) {
|
|
332
|
+
this.poisoned ??= error;
|
|
333
|
+
throw error;
|
|
334
|
+
}
|
|
335
|
+
assertNoEntryWrites(conversationId, read) {
|
|
336
|
+
if (this.wroteEntries.has(conversationId))
|
|
337
|
+
this.poison(new ReadAfterWrite(read, "entry append"));
|
|
338
|
+
}
|
|
339
|
+
assertNoTaskWrites(read) {
|
|
340
|
+
if (this.wroteTasks)
|
|
341
|
+
this.poison(new ReadAfterWrite(read, "task write"));
|
|
342
|
+
}
|
|
343
|
+
get poison_() {
|
|
344
|
+
return this.poisoned;
|
|
345
|
+
}
|
|
346
|
+
// --- reads (direct reads see this transaction's writes) -------------------
|
|
347
|
+
async conversation(id) {
|
|
348
|
+
this.assertScope(id, "conversation");
|
|
349
|
+
return this.createdConversations.get(id) ?? (await this.storage.conversation(id, this.ctx));
|
|
350
|
+
}
|
|
351
|
+
async entry(a, b) {
|
|
352
|
+
const id = typeof a === "number" ? a : b;
|
|
353
|
+
const e = this.createdEntries.get(id) ?? (await this.storage.entries([id], this.ctx)).get(id);
|
|
354
|
+
if (e !== undefined)
|
|
355
|
+
this.assertEntryScope(e, "entry");
|
|
356
|
+
return typeof a === "number" || a.is(e) ? e : undefined;
|
|
357
|
+
}
|
|
358
|
+
async entries(ids) {
|
|
359
|
+
const out = await this.storage.entries(ids.filter((id) => !this.createdEntries.has(id)), this.ctx);
|
|
360
|
+
for (const id of ids) {
|
|
361
|
+
const c = this.createdEntries.get(id);
|
|
362
|
+
if (c)
|
|
363
|
+
out.set(id, c);
|
|
364
|
+
}
|
|
365
|
+
for (const entry of out.values())
|
|
366
|
+
this.assertEntryScope(entry, "entries");
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
async newestEntry(conversationId, opts = {}) {
|
|
370
|
+
this.assertScope(conversationId, "newestEntry");
|
|
371
|
+
this.assertNoEntryWrites(conversationId, "newestEntry");
|
|
372
|
+
const scan = "is" in opts ? { kind: opts.kind } : opts;
|
|
373
|
+
const [e] = await this.storage.scanEntries({ conversationId, ...scan, limit: 1 }, this.ctx);
|
|
374
|
+
return e;
|
|
375
|
+
}
|
|
376
|
+
scanEntries(scan) {
|
|
377
|
+
this.assertScope(scan.conversationId, "scanEntries");
|
|
378
|
+
this.assertNoEntryWrites(scan.conversationId, "scanEntries");
|
|
379
|
+
return this.storage.scanEntries(scan, this.ctx);
|
|
380
|
+
}
|
|
381
|
+
async context(conversationId, at) {
|
|
382
|
+
this.assertScope(conversationId, "context");
|
|
383
|
+
this.assertNoEntryWrites(conversationId, "context");
|
|
384
|
+
return deriveContext(this.storage, conversationId, at, this.ctx);
|
|
385
|
+
}
|
|
386
|
+
async task(a) {
|
|
387
|
+
const id = typeof a === "number" ? a : a.id;
|
|
388
|
+
const task = this.createdTasks.get(id) ?? this.liveTasks.get(id) ?? (await this.storage.task(id, this.ctx));
|
|
389
|
+
if (task !== undefined)
|
|
390
|
+
this.assertScope(task.conversationId, "task");
|
|
391
|
+
return task;
|
|
392
|
+
}
|
|
393
|
+
async tasks(scan) {
|
|
394
|
+
this.assertNoTaskWrites("tasks");
|
|
395
|
+
if (scan.conversationId !== undefined)
|
|
396
|
+
this.assertScope(scan.conversationId, "tasks");
|
|
397
|
+
const rows = await this.storage.scanTasks(scan, this.ctx);
|
|
398
|
+
const seen = new Set(rows.map((t) => t.id));
|
|
399
|
+
for (const t of this.liveTasks.values())
|
|
400
|
+
if (!seen.has(t.id) &&
|
|
401
|
+
(scan.conversationId === undefined || t.conversationId === scan.conversationId) &&
|
|
402
|
+
(scan.kind === undefined || t.kind === scan.kind))
|
|
403
|
+
rows.push(t);
|
|
404
|
+
return rows.filter((t) => scan.status === undefined || scan.status.includes(t.status));
|
|
405
|
+
}
|
|
406
|
+
async input(id) {
|
|
407
|
+
const input = this.inputsById.get(id) ?? (await this.storage.input(id, this.ctx));
|
|
408
|
+
if (input !== undefined)
|
|
409
|
+
this.assertScope(input.conversationId, "input");
|
|
410
|
+
return input;
|
|
411
|
+
}
|
|
412
|
+
async inputByRequest(conversationId, requestId) {
|
|
413
|
+
this.assertScope(conversationId, "inputByRequest");
|
|
414
|
+
return (this.inputsByRequest.get(`${conversationId}:${requestId}`) ??
|
|
415
|
+
(await this.storage.inputByRequest(conversationId, requestId, this.ctx)));
|
|
416
|
+
}
|
|
417
|
+
async rewindableAsOf(conversationId, at) {
|
|
418
|
+
this.assertScope(conversationId, "rewindableAsOf");
|
|
419
|
+
return this.storage.docAsOf(conversationId, at, this.ctx);
|
|
420
|
+
}
|
|
421
|
+
snapshot(ref) {
|
|
422
|
+
if (ref.doc !== "session")
|
|
423
|
+
this.assertScope(ref.conversationId, "snapshot");
|
|
424
|
+
const value = structuredClone(this.doc(ref).target);
|
|
425
|
+
if (ref.doc !== "session") {
|
|
426
|
+
this.defaults.fill(ref.doc, value);
|
|
427
|
+
if (this.invoker.type === "task") {
|
|
428
|
+
for (const [key, fallback] of Object.entries(this.invoker.kind.config?.[ref.doc] ?? {})) {
|
|
429
|
+
if (!(key in value) && fallback !== undefined)
|
|
430
|
+
value[key] = structuredClone(fallback);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
// --- documents ----------------------------------------------------------
|
|
437
|
+
doc(ref) {
|
|
438
|
+
const key = docKey(ref);
|
|
439
|
+
const hit = this.touched.get(key);
|
|
440
|
+
if (hit !== undefined)
|
|
441
|
+
return hit.tracker;
|
|
442
|
+
const cached = this.docs.peek(ref);
|
|
443
|
+
if (cached !== undefined) {
|
|
444
|
+
this.touched.set(key, { ref, tracker: cached });
|
|
445
|
+
return cached;
|
|
446
|
+
}
|
|
447
|
+
throw new Error(`document ${key} not loaded; pass it in commit({ docs })`);
|
|
448
|
+
}
|
|
449
|
+
view(ref) {
|
|
450
|
+
return this.membrane.wrap(this.doc(ref).state);
|
|
451
|
+
}
|
|
452
|
+
async preload(refs) {
|
|
453
|
+
for (const ref of refs) {
|
|
454
|
+
const key = docKey(ref);
|
|
455
|
+
if (this.touched.has(key))
|
|
456
|
+
continue;
|
|
457
|
+
this.touched.set(key, { ref, tracker: await this.docs.get(ref, this.ctx) });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
rewindable(conversationId) {
|
|
461
|
+
this.assertCore("rewindable document");
|
|
462
|
+
return this.view({ doc: "rewindable", conversationId });
|
|
463
|
+
}
|
|
464
|
+
sticky(conversationId) {
|
|
465
|
+
this.assertCore("sticky document");
|
|
466
|
+
return this.view({ doc: "sticky", conversationId });
|
|
467
|
+
}
|
|
468
|
+
session() {
|
|
469
|
+
this.assertCore("session document");
|
|
470
|
+
return this.view({ doc: "session" });
|
|
471
|
+
}
|
|
472
|
+
/** Internal, unchecked. */
|
|
473
|
+
raw(ref) {
|
|
474
|
+
return this.view(ref);
|
|
475
|
+
}
|
|
476
|
+
plugins(namespace) {
|
|
477
|
+
const registration = this.namespaces.get(namespace.id);
|
|
478
|
+
if (registration?.token !== namespace)
|
|
479
|
+
throw new Forbidden(`namespace "${namespace.id}" is stale`);
|
|
480
|
+
const cached = this.namespaceViews.get(namespace);
|
|
481
|
+
if (cached !== undefined)
|
|
482
|
+
return cached;
|
|
483
|
+
const conversationId = this.invocationConversationId(`plugins(${namespace.id})`);
|
|
484
|
+
const target = {};
|
|
485
|
+
for (const [key, doc] of registration.routes) {
|
|
486
|
+
const ref = doc === "session" ? { doc } : { doc, conversationId };
|
|
487
|
+
const document = this.raw(ref);
|
|
488
|
+
if (!Object.hasOwn(document.plugins, namespace.id))
|
|
489
|
+
document.plugins[namespace.id] = {};
|
|
490
|
+
const slice = document.plugins[namespace.id];
|
|
491
|
+
const defaultValue = registration.defaults[doc][key];
|
|
492
|
+
if (!(key in slice) && defaultValue !== undefined)
|
|
493
|
+
slice[key] = plain(defaultValue);
|
|
494
|
+
Object.defineProperty(target, key, {
|
|
495
|
+
enumerable: true,
|
|
496
|
+
get: () => slice[key],
|
|
497
|
+
set: (value) => {
|
|
498
|
+
slice[key] = value;
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
Object.preventExtensions(target);
|
|
503
|
+
const view = this.membrane.wrap(target);
|
|
504
|
+
this.namespaceViews.set(namespace, view);
|
|
505
|
+
return view;
|
|
506
|
+
}
|
|
507
|
+
emit(namespaceOrEvent, name, data) {
|
|
508
|
+
const conversationId = this.invocationConversationId("emit");
|
|
509
|
+
if ("id" in namespaceOrEvent && "unregister" in namespaceOrEvent) {
|
|
510
|
+
if (this.namespaces.get(namespaceOrEvent.id)?.token !== namespaceOrEvent)
|
|
511
|
+
throw new Forbidden(`namespace "${namespaceOrEvent.id}" is stale`);
|
|
512
|
+
if (name === undefined || data === undefined || !/^[a-z][a-z0-9_.-]*$/i.test(name))
|
|
513
|
+
throw new Error("invalid plugin event");
|
|
514
|
+
this.changes.events.push({
|
|
515
|
+
conversationId,
|
|
516
|
+
event: { type: `plugin.${namespaceOrEvent.id}.${name}`, data: plain(data) },
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.assertCore("core event");
|
|
521
|
+
this.changes.events.push({ conversationId, event: plain(namespaceOrEvent) });
|
|
522
|
+
}
|
|
523
|
+
invocationConversationId(what) {
|
|
524
|
+
const conversationId = this.invoker.conversationId;
|
|
525
|
+
if (conversationId === undefined)
|
|
526
|
+
throw new Forbidden(`${what}: no conversation is bound to this transaction`);
|
|
527
|
+
this.assertScope(conversationId, what);
|
|
528
|
+
return conversationId;
|
|
529
|
+
}
|
|
530
|
+
config(conversationId) {
|
|
531
|
+
this.assertScope(conversationId, "config");
|
|
532
|
+
const definition = (key) => {
|
|
533
|
+
if (this.invoker.type === "task") {
|
|
534
|
+
for (const doc of ["rewindable", "sticky"]) {
|
|
535
|
+
const declared = this.invoker.kind.config?.[doc];
|
|
536
|
+
if (declared !== undefined && Object.hasOwn(declared, key))
|
|
537
|
+
return { doc, fallback: declared[key] };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const doc = this.defaults.route.get(key);
|
|
541
|
+
if (doc === undefined)
|
|
542
|
+
throw new Error(`unknown config key "${key}"`);
|
|
543
|
+
return { doc, fallback: this.defaults[doc][key] };
|
|
544
|
+
};
|
|
545
|
+
const assertWritable = (key) => {
|
|
546
|
+
if (this.invoker.type === "task" && !this.invoker.core)
|
|
547
|
+
throw new Forbidden(`config(${key}): ordinary tasks cannot write config`);
|
|
548
|
+
};
|
|
549
|
+
return {
|
|
550
|
+
get: (key) => {
|
|
551
|
+
this.assertSurfaceActive();
|
|
552
|
+
const { doc, fallback } = definition(key);
|
|
553
|
+
const document = this.doc({ doc, conversationId }).target;
|
|
554
|
+
return key in document ? structuredClone(document[key]) : structuredClone(fallback);
|
|
555
|
+
},
|
|
556
|
+
set: (key, value) => {
|
|
557
|
+
this.assertSurfaceActive();
|
|
558
|
+
assertWritable(key);
|
|
559
|
+
const { doc } = definition(key);
|
|
560
|
+
if (!this.defaults.validate(key, value))
|
|
561
|
+
throw new TypeError(`invalid config value for "${key}"`);
|
|
562
|
+
this.raw({ doc, conversationId })[key] = structuredClone(value);
|
|
563
|
+
const keys = this.changedConfig.get(conversationId) ?? new Set();
|
|
564
|
+
keys.add(key);
|
|
565
|
+
this.changedConfig.set(conversationId, keys);
|
|
566
|
+
},
|
|
567
|
+
reset: (key) => {
|
|
568
|
+
this.assertSurfaceActive();
|
|
569
|
+
assertWritable(key);
|
|
570
|
+
const { doc } = definition(key);
|
|
571
|
+
delete this.raw({ doc, conversationId })[key];
|
|
572
|
+
const keys = this.changedConfig.get(conversationId) ?? new Set();
|
|
573
|
+
keys.add(key);
|
|
574
|
+
this.changedConfig.set(conversationId, keys);
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
slot(ref) {
|
|
579
|
+
this.assertNotHost("slot");
|
|
580
|
+
const inv = this.invoker;
|
|
581
|
+
const task = this.liveTasks.get(ref.id) ?? this.createdTasks.get(ref.id);
|
|
582
|
+
if (task === undefined)
|
|
583
|
+
throw new Error(`task ${ref.id} is not live`);
|
|
584
|
+
if (!inv.core && ref.id !== inv.id)
|
|
585
|
+
throw new Forbidden("slot: another task's slot");
|
|
586
|
+
const s = this.raw({ doc: "sticky", conversationId: task.conversationId });
|
|
587
|
+
if (!(String(ref.id) in s.tasks))
|
|
588
|
+
s.tasks[ref.id] = (ref.kind.slot ?? (() => ({})))(task.input);
|
|
589
|
+
return s.tasks[ref.id];
|
|
590
|
+
}
|
|
591
|
+
toolSlot(task) {
|
|
592
|
+
this.assertCore("toolSlot");
|
|
593
|
+
const slot = this.raw({ doc: "sticky", conversationId: task.conversationId }).turn.tools[task.input.index];
|
|
594
|
+
if (slot === undefined)
|
|
595
|
+
throw new Error(`no tool slot at index ${task.input.index}`);
|
|
596
|
+
return slot;
|
|
597
|
+
}
|
|
598
|
+
appendEntry(conversationId, a, b) {
|
|
599
|
+
this.assertCore("appendEntry");
|
|
600
|
+
if ("is" in a)
|
|
601
|
+
return {
|
|
602
|
+
id: this.appendEntryInternal(conversationId, { ...b, kind: a.kind }),
|
|
603
|
+
kind: a,
|
|
604
|
+
};
|
|
605
|
+
return this.appendEntryInternal(conversationId, a);
|
|
606
|
+
}
|
|
607
|
+
appendEntryInternal(conversationId, entry) {
|
|
608
|
+
if (this.invoker.type === "task" && !this.liveTasks.has(this.invoker.id) && !this.closing)
|
|
609
|
+
throw new Forbidden("appendEntry from a task that is not live");
|
|
610
|
+
const id = this.storage.mintId();
|
|
611
|
+
const head = entry.head === "self" ? id : entry.head;
|
|
612
|
+
const { head: _h, ...rest } = entry;
|
|
613
|
+
const record = plain({
|
|
614
|
+
...rest,
|
|
615
|
+
id,
|
|
616
|
+
conversationId,
|
|
617
|
+
...(head === undefined ? {} : { head }),
|
|
618
|
+
...(this.invoker.type === "task" ? { byTaskId: this.invoker.id } : {}),
|
|
619
|
+
});
|
|
620
|
+
validateEntry(record);
|
|
621
|
+
this.writes.push({ type: "entry", entry: record });
|
|
622
|
+
this.changes.entries.push(record);
|
|
623
|
+
if (record.head !== undefined)
|
|
624
|
+
this.changes.events.push({ conversationId, event: { type: "head.moved", entry: record } });
|
|
625
|
+
this.changes.events.push({ conversationId, event: { type: "entry.added", entry: record } });
|
|
626
|
+
this.createdEntries.set(id, record);
|
|
627
|
+
this.wroteEntries.add(conversationId);
|
|
628
|
+
return id;
|
|
629
|
+
}
|
|
630
|
+
async write(conversationId, a, b) {
|
|
631
|
+
this.assertScope(conversationId, "write");
|
|
632
|
+
const entry = "is" in a ? { ...b, kind: a.kind } : a;
|
|
633
|
+
if (!this.core) {
|
|
634
|
+
if (entry.head !== undefined)
|
|
635
|
+
throw new Forbidden("write: head entries are core only");
|
|
636
|
+
if (entry.edits !== undefined)
|
|
637
|
+
throw new Forbidden("write: edits are core only");
|
|
638
|
+
if (entry.kind.startsWith("pi.") && entry.kind !== "pi.notice")
|
|
639
|
+
throw new Forbidden(`write: kind ${entry.kind} is reserved`);
|
|
640
|
+
if (entry.kind === "pi.notice" && (entry.model?.length !== 1 || entry.model[0]?.role !== "user")) {
|
|
641
|
+
throw new Forbidden("write: pi.notice requires exactly one user model message");
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (this.busy(conversationId)) {
|
|
645
|
+
const id = this.storage.mintId();
|
|
646
|
+
this.raw({ doc: "sticky", conversationId }).inbox.push({
|
|
647
|
+
id,
|
|
648
|
+
mode: "write",
|
|
649
|
+
entry: structuredClone(entry),
|
|
650
|
+
});
|
|
651
|
+
this.putInput({ id, conversationId, status: "queued" });
|
|
652
|
+
this.changes.events.push({ conversationId, event: { type: "input.queued", input: id, mode: "write" } });
|
|
653
|
+
return id;
|
|
654
|
+
}
|
|
655
|
+
const id = this.storage.mintId();
|
|
656
|
+
const e = this.appendEntryInternal(conversationId, entry);
|
|
657
|
+
this.putInput({ id, conversationId, status: "done", entry: e });
|
|
658
|
+
return id;
|
|
659
|
+
}
|
|
660
|
+
checkpoint(value) {
|
|
661
|
+
this.assertNotHost("checkpoint");
|
|
662
|
+
const inv = this.invoker;
|
|
663
|
+
if (inv.mode === "abort")
|
|
664
|
+
throw new Forbidden("checkpoint from an abort invocation");
|
|
665
|
+
const current = this.createdTasks.get(inv.id) ?? this.liveTasks.get(inv.id);
|
|
666
|
+
if (current === undefined)
|
|
667
|
+
throw new Error("task not live");
|
|
668
|
+
this.setTaskInternal({ ...current, checkpoint: plain(value) });
|
|
669
|
+
}
|
|
670
|
+
/** Kernel-internal: replace a task's mutable fields. Persists only what changed. */
|
|
671
|
+
setTask(task) {
|
|
672
|
+
this.assertCore("setTask");
|
|
673
|
+
this.setTaskInternal(task);
|
|
674
|
+
}
|
|
675
|
+
setTaskInternalForControl(task) {
|
|
676
|
+
this.setTaskInternal(task);
|
|
677
|
+
}
|
|
678
|
+
setTaskInternal(task) {
|
|
679
|
+
task = plain(task);
|
|
680
|
+
const prev = this.createdTasks.get(task.id) ?? this.liveTasks.get(task.id);
|
|
681
|
+
const patch = { id: task.id };
|
|
682
|
+
for (const key of ["status", "checkpoint", "abort", "outcome", "owns"]) {
|
|
683
|
+
if (JSON.stringify(prev?.[key]) === JSON.stringify(task[key]))
|
|
684
|
+
continue;
|
|
685
|
+
patch[key] = key === "checkpoint" && task.checkpoint === undefined ? null : task[key];
|
|
686
|
+
}
|
|
687
|
+
if (task.status === "terminal") {
|
|
688
|
+
patch.status = "terminal";
|
|
689
|
+
patch.checkpoint = null;
|
|
690
|
+
patch.owns = task.owns;
|
|
691
|
+
patch.outcome = task.outcome;
|
|
692
|
+
if (task.abort === true)
|
|
693
|
+
patch.abort = true;
|
|
694
|
+
task = { ...task };
|
|
695
|
+
delete task.checkpoint;
|
|
696
|
+
}
|
|
697
|
+
if (Object.keys(patch).length === 1)
|
|
698
|
+
return;
|
|
699
|
+
if (task.status === "terminal")
|
|
700
|
+
delete this.raw({ doc: "sticky", conversationId: task.conversationId }).tasks[task.id];
|
|
701
|
+
this.writes.push({ type: "task.patch", patch: patch });
|
|
702
|
+
this.changes.tasks.push(task);
|
|
703
|
+
if (task.status === "terminal" && prev?.status !== "terminal") {
|
|
704
|
+
const kind = this.kinds.get(task.kind);
|
|
705
|
+
if (task.kind === "pi.collapse") {
|
|
706
|
+
if (task.outcome?.status === "completed") {
|
|
707
|
+
const result = task.outcome.result;
|
|
708
|
+
this.changes.events.push({
|
|
709
|
+
conversationId: task.conversationId,
|
|
710
|
+
event: { type: "compaction.finished", taskId: task.id, summary: result.summary },
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
else if (task.outcome?.status === "failed") {
|
|
714
|
+
const failure = task.outcome.failure;
|
|
715
|
+
this.changes.events.push({
|
|
716
|
+
conversationId: task.conversationId,
|
|
717
|
+
event: { type: "compaction.failed", taskId: task.id, reason: failure.reason, detail: failure.detail },
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
else if ((kind === undefined || kind.turn !== true) && task.outcome !== undefined) {
|
|
722
|
+
this.changes.events.push({
|
|
723
|
+
conversationId: task.conversationId,
|
|
724
|
+
event: { type: "task.ended", taskId: task.id, kind: task.kind, outcome: task.outcome.status },
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
this.createdTasks.set(task.id, task); // overlay: later direct reads see the patch
|
|
729
|
+
this.wroteTasks = true;
|
|
730
|
+
}
|
|
731
|
+
createTask(a, input, opts = {}) {
|
|
732
|
+
if ("initial" in a) {
|
|
733
|
+
const registered = this.kinds.get(a.name);
|
|
734
|
+
if (registered !== a)
|
|
735
|
+
throw new Forbidden(`createTask: kind "${a.name}" is not the registered token`);
|
|
736
|
+
return { id: this.createTaskInternal({ kind: a.name, input: input, ...opts }), kind: a };
|
|
737
|
+
}
|
|
738
|
+
this.assertCore("createTask by name");
|
|
739
|
+
return this.createTaskInternal(a);
|
|
740
|
+
}
|
|
741
|
+
createTaskInternal(spec) {
|
|
742
|
+
const kind = this.kinds.get(spec.kind);
|
|
743
|
+
if (kind === undefined)
|
|
744
|
+
throw new Error(`unknown task kind ${spec.kind}`);
|
|
745
|
+
if (isCoreKind(spec.kind) && !this.core)
|
|
746
|
+
throw new Forbidden(`create core task ${spec.kind}`);
|
|
747
|
+
const conversationId = spec.conversationId ?? (this.invoker.type === "task" ? this.invoker.conversationId : undefined);
|
|
748
|
+
if (conversationId === undefined)
|
|
749
|
+
throw new Error("createTask: conversationId required");
|
|
750
|
+
this.assertScope(conversationId, "createTask");
|
|
751
|
+
if (spec.kind === "pi.generation" && this.hasLiveKind(conversationId, spec.kind))
|
|
752
|
+
throw new GenerationInProgress(conversationId);
|
|
753
|
+
if (spec.kind === "pi.collapse" && this.hasLiveKind(conversationId, spec.kind))
|
|
754
|
+
throw new CollapseInProgress(conversationId);
|
|
755
|
+
const id = this.storage.mintId();
|
|
756
|
+
const task = {
|
|
757
|
+
id,
|
|
758
|
+
conversationId,
|
|
759
|
+
kind: spec.kind,
|
|
760
|
+
input: plain(spec.input),
|
|
761
|
+
status: "pending",
|
|
762
|
+
after: [...(spec.after ?? [])],
|
|
763
|
+
owns: [],
|
|
764
|
+
...(spec.background ? { background: true } : {}),
|
|
765
|
+
};
|
|
766
|
+
this.writes.push({ type: "task", task });
|
|
767
|
+
this.changes.tasks.push(task);
|
|
768
|
+
if (spec.kind === "pi.collapse") {
|
|
769
|
+
const input = spec.input;
|
|
770
|
+
this.changes.events.push({
|
|
771
|
+
conversationId,
|
|
772
|
+
event: { type: "compaction.started", taskId: id, reason: input.reason, through: input.through },
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
else if (kind.turn !== true) {
|
|
776
|
+
this.changes.events.push({
|
|
777
|
+
conversationId,
|
|
778
|
+
event: {
|
|
779
|
+
type: "task.started",
|
|
780
|
+
taskId: id,
|
|
781
|
+
kind: task.kind,
|
|
782
|
+
...(task.background ? { background: true } : {}),
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
this.createdTasks.set(id, task);
|
|
787
|
+
this.wroteTasks = true;
|
|
788
|
+
return id;
|
|
789
|
+
}
|
|
790
|
+
hasLiveKind(conversationId, kind) {
|
|
791
|
+
for (const task of this.liveTasks.values()) {
|
|
792
|
+
if (task.conversationId !== conversationId || task.kind !== kind)
|
|
793
|
+
continue;
|
|
794
|
+
const current = this.createdTasks.get(task.id) ?? task;
|
|
795
|
+
if (current.status === "terminal")
|
|
796
|
+
continue;
|
|
797
|
+
if (this.closing && this.invoker.type === "task" && this.invoker.id === task.id)
|
|
798
|
+
continue;
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
for (const task of this.createdTasks.values()) {
|
|
802
|
+
if (task.conversationId === conversationId &&
|
|
803
|
+
task.kind === kind &&
|
|
804
|
+
task.status !== "terminal" &&
|
|
805
|
+
!this.liveTasks.has(task.id))
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
createConversation(spec) {
|
|
811
|
+
const owner = this.invoker.type === "task" ? this.invoker.id : undefined;
|
|
812
|
+
if (spec.parent !== undefined)
|
|
813
|
+
this.assertScope(spec.parent.conversationId, "createConversation: parent");
|
|
814
|
+
return this.insertConversation(spec, owner, false);
|
|
815
|
+
}
|
|
816
|
+
createForkConversation(spec) {
|
|
817
|
+
this.assertCore("createForkConversation");
|
|
818
|
+
return this.insertConversation(spec, undefined, true);
|
|
819
|
+
}
|
|
820
|
+
async createOwnedConversation(ownerTaskId, sourceConversationId, spec) {
|
|
821
|
+
this.assertCore("createOwnedConversation");
|
|
822
|
+
const owner = this.liveTasks.get(ownerTaskId) ?? this.createdTasks.get(ownerTaskId);
|
|
823
|
+
if (owner === undefined || owner.status === "terminal" || owner.conversationId !== sourceConversationId)
|
|
824
|
+
throw new Forbidden(`task ${ownerTaskId} cannot create an owned conversation`);
|
|
825
|
+
const tip = spec.inherit ? await this.newestEntry(sourceConversationId) : undefined;
|
|
826
|
+
const inherited = tip === undefined ? undefined : await this.rewindableAsOf(sourceConversationId, tip.id);
|
|
827
|
+
const rawOverrides = structuredClone(spec.rewindable ?? {});
|
|
828
|
+
delete rawOverrides.plugins;
|
|
829
|
+
const overrides = this.defaults.validateSeed("rewindable", rawOverrides);
|
|
830
|
+
const rewindable = inherited === undefined
|
|
831
|
+
? overrides
|
|
832
|
+
: { ...inherited, ...overrides, plugins: structuredClone(inherited.plugins) };
|
|
833
|
+
return this.insertConversation({
|
|
834
|
+
...(tip === undefined ? {} : { parent: { conversationId: sourceConversationId, at: tip.id } }),
|
|
835
|
+
rewindable,
|
|
836
|
+
sticky: spec.sticky,
|
|
837
|
+
}, ownerTaskId, inherited !== undefined);
|
|
838
|
+
}
|
|
839
|
+
insertConversation(spec, owner, preservePlugins) {
|
|
840
|
+
const id = this.storage.mintId();
|
|
841
|
+
const parent = spec.parent === undefined || spec.parent.at === "start"
|
|
842
|
+
? undefined
|
|
843
|
+
: { conversationId: spec.parent.conversationId, at: spec.parent.at };
|
|
844
|
+
const conversation = {
|
|
845
|
+
id,
|
|
846
|
+
...(parent ? { parent } : {}),
|
|
847
|
+
...(owner === undefined ? {} : { owner }),
|
|
848
|
+
...(spec.sections?.length ? { sections: spec.sections } : {}),
|
|
849
|
+
};
|
|
850
|
+
this.writes.push({ type: "conversation", conversation });
|
|
851
|
+
this.changes.conversations.push(conversation);
|
|
852
|
+
this.createdConversations.set(id, conversation);
|
|
853
|
+
this.seedDoc({ doc: "rewindable", conversationId: id }, this.defaults.freshRewindable(spec.rewindable, preservePlugins));
|
|
854
|
+
this.seedDoc({ doc: "sticky", conversationId: id }, this.defaults.freshSticky(spec.sticky));
|
|
855
|
+
if (owner !== undefined) {
|
|
856
|
+
const task = this.liveTasks.get(owner) ?? this.createdTasks.get(owner);
|
|
857
|
+
if (task !== undefined)
|
|
858
|
+
this.setTaskInternal({ ...task, owns: [...task.owns, id] });
|
|
859
|
+
}
|
|
860
|
+
return id;
|
|
861
|
+
}
|
|
862
|
+
seedDoc(ref, value) {
|
|
863
|
+
const tracker = track(value);
|
|
864
|
+
tracker.rebase();
|
|
865
|
+
const base = tracker.flush();
|
|
866
|
+
this.writes.push({ type: "doc", ref, ops: base });
|
|
867
|
+
this.changes.docs.push({ ref, ops: base });
|
|
868
|
+
this.docs.adopt(ref, tracker);
|
|
869
|
+
this.touched.set(docKey(ref), { ref, tracker });
|
|
870
|
+
}
|
|
871
|
+
markTask(id) {
|
|
872
|
+
this.assertCore("markTask");
|
|
873
|
+
const task = this.createdTasks.get(id) ?? this.liveTasks.get(id);
|
|
874
|
+
if (task === undefined)
|
|
875
|
+
throw new Error(`task ${id} not live`);
|
|
876
|
+
if (task.abort !== true)
|
|
877
|
+
this.setTaskInternal({ ...task, abort: true });
|
|
878
|
+
}
|
|
879
|
+
// --- admission ----------------------------------------------------------
|
|
880
|
+
/** Prospective: live turn tasks, minus this transaction's terminals and the closing task, plus this transaction's new turn tasks. */
|
|
881
|
+
busy(conversationId) {
|
|
882
|
+
const isTurn = (t) => this.kinds.get(t.kind)?.turn === true && t.background !== true;
|
|
883
|
+
for (const t of this.liveTasks.values()) {
|
|
884
|
+
if (t.conversationId !== conversationId || !isTurn(t))
|
|
885
|
+
continue;
|
|
886
|
+
const overlay = this.createdTasks.get(t.id);
|
|
887
|
+
if (overlay?.status === "terminal")
|
|
888
|
+
continue;
|
|
889
|
+
if (this.closing && this.invoker.type === "task" && t.id === this.invoker.id)
|
|
890
|
+
continue;
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
for (const t of this.createdTasks.values())
|
|
894
|
+
if (t.conversationId === conversationId && isTurn(t) && t.status !== "terminal" && !this.liveTasks.has(t.id))
|
|
895
|
+
return true;
|
|
896
|
+
return false;
|
|
897
|
+
}
|
|
898
|
+
putInput(input) {
|
|
899
|
+
input = plain(input);
|
|
900
|
+
this.writes.push({ type: "input", input });
|
|
901
|
+
this.changes.inputs.push(input);
|
|
902
|
+
this.inputsById.set(input.id, input);
|
|
903
|
+
if (input.requestId !== undefined)
|
|
904
|
+
this.inputsByRequest.set(`${input.conversationId}:${input.requestId}`, input);
|
|
905
|
+
}
|
|
906
|
+
async send(conversationId, input) {
|
|
907
|
+
this.assertCore("send");
|
|
908
|
+
if (input.requestId !== undefined) {
|
|
909
|
+
const existing = await this.inputByRequest(conversationId, input.requestId); // before any write
|
|
910
|
+
if (existing !== undefined)
|
|
911
|
+
return existing.id;
|
|
912
|
+
}
|
|
913
|
+
if (this.busy(conversationId)) {
|
|
914
|
+
const mode = input.whenBusy ?? "followUp";
|
|
915
|
+
if (mode === "reject")
|
|
916
|
+
throw new ConversationBusy(conversationId);
|
|
917
|
+
const id = this.storage.mintId();
|
|
918
|
+
this.raw({ doc: "sticky", conversationId }).inbox.push({
|
|
919
|
+
id,
|
|
920
|
+
mode,
|
|
921
|
+
input: structuredClone(input.content),
|
|
922
|
+
});
|
|
923
|
+
this.putInput({
|
|
924
|
+
id,
|
|
925
|
+
conversationId,
|
|
926
|
+
status: "queued",
|
|
927
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
928
|
+
});
|
|
929
|
+
this.changes.events.push({ conversationId, event: { type: "input.queued", input: id, mode } });
|
|
930
|
+
return id;
|
|
931
|
+
}
|
|
932
|
+
// Idle: read the head before the first append, place older queued items, then this one.
|
|
933
|
+
const head = await this.newestEntry(conversationId, { withHead: true });
|
|
934
|
+
const { triggers } = await this.boundary(conversationId, "final", head?.id);
|
|
935
|
+
const id = this.storage.mintId();
|
|
936
|
+
const eventStart = this.changes.events.length;
|
|
937
|
+
const entry = this.appendEntryInternal(conversationId, {
|
|
938
|
+
kind: "pi.user",
|
|
939
|
+
model: [{ role: "user", content: input.content, timestamp: this.now() }],
|
|
940
|
+
});
|
|
941
|
+
const entryEvents = this.changes.events.splice(eventStart);
|
|
942
|
+
this.putInput({
|
|
943
|
+
id,
|
|
944
|
+
conversationId,
|
|
945
|
+
status: "placed",
|
|
946
|
+
entry,
|
|
947
|
+
...(input.requestId ? { requestId: input.requestId } : {}),
|
|
948
|
+
});
|
|
949
|
+
this.changes.events.push({ conversationId, event: { type: "input.placed", input: id, entry } }, ...entryEvents);
|
|
950
|
+
const inputs = [...triggers, id];
|
|
951
|
+
this.createTaskInternal({ kind: "pi.generation", conversationId, input: { inputs } });
|
|
952
|
+
this.changes.events.push({ conversationId, event: { type: "turn.started", inputs } });
|
|
953
|
+
return id;
|
|
954
|
+
}
|
|
955
|
+
async setInput(id, patch) {
|
|
956
|
+
const current = await this.input(id);
|
|
957
|
+
if (current === undefined)
|
|
958
|
+
throw new Error(`input ${id} not found`);
|
|
959
|
+
this.putInput({ ...current, ...patch });
|
|
960
|
+
}
|
|
961
|
+
/** Kernel: withdraw a queued input. */
|
|
962
|
+
async withdrawInput(id) {
|
|
963
|
+
this.assertCore("withdrawInput");
|
|
964
|
+
const i = await this.input(id);
|
|
965
|
+
if (i === undefined)
|
|
966
|
+
return "not_found";
|
|
967
|
+
if (i.status !== "queued")
|
|
968
|
+
return "already_placed";
|
|
969
|
+
await this.preload([{ doc: "sticky", conversationId: i.conversationId }]);
|
|
970
|
+
removeWhere(this.raw({ doc: "sticky", conversationId: i.conversationId }).inbox, (q) => q.id === id);
|
|
971
|
+
await this.setInput(id, { status: "unanswered", reason: "aborted" });
|
|
972
|
+
this.changes.events.push({ conversationId: i.conversationId, event: { type: "input.aborted", input: id } });
|
|
973
|
+
return "aborted";
|
|
974
|
+
}
|
|
975
|
+
async resolveInputs(ids, resolution) {
|
|
976
|
+
this.assertCore("resolveInputs");
|
|
977
|
+
for (const id of ids)
|
|
978
|
+
await this.setInput(id, resolution);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Boundary placement (pico §9.5). No storage scan: `headBoundary` is the newest head as the
|
|
982
|
+
* caller knows it, and it advances locally as same-batch self-heads are placed.
|
|
983
|
+
*/
|
|
984
|
+
async boundary(conversationId, at, headBoundary) {
|
|
985
|
+
this.assertCore("boundary");
|
|
986
|
+
const s = this.raw({ doc: "sticky", conversationId });
|
|
987
|
+
const inbox = [...s.inbox].sort((a, b) => a.id - b.id);
|
|
988
|
+
let cut;
|
|
989
|
+
for (const q of inbox)
|
|
990
|
+
if (q.mode === "write" && q.entry.head === "self")
|
|
991
|
+
cut = q.id;
|
|
992
|
+
const stale = cut === undefined ? [] : inbox.filter((q) => q.mode !== "write" && q.id < cut).map((q) => q.id);
|
|
993
|
+
for (const id of stale) {
|
|
994
|
+
await this.setInput(id, { status: "unanswered", reason: "stale" });
|
|
995
|
+
this.changes.events.push({ conversationId, event: { type: "input.aborted", input: id } });
|
|
996
|
+
}
|
|
997
|
+
const survivors = inbox.filter((q) => !stale.includes(q.id));
|
|
998
|
+
const pick = (mode, policy) => {
|
|
999
|
+
const items = survivors.filter((q) => q.mode === mode);
|
|
1000
|
+
return policy === "all" ? items : items.slice(0, 1);
|
|
1001
|
+
};
|
|
1002
|
+
const selected = new Set(stale);
|
|
1003
|
+
for (const q of survivors)
|
|
1004
|
+
if (q.mode === "write")
|
|
1005
|
+
selected.add(q.id);
|
|
1006
|
+
for (const q of pick("steer", s.steeringMode))
|
|
1007
|
+
selected.add(q.id);
|
|
1008
|
+
if (at === "final")
|
|
1009
|
+
for (const q of pick("followUp", s.followUpMode))
|
|
1010
|
+
selected.add(q.id);
|
|
1011
|
+
let head = headBoundary;
|
|
1012
|
+
const triggers = [];
|
|
1013
|
+
for (const q of survivors) {
|
|
1014
|
+
if (!selected.has(q.id))
|
|
1015
|
+
continue;
|
|
1016
|
+
if (q.mode === "write") {
|
|
1017
|
+
if (q.entry.head !== undefined && q.entry.head !== "self" && head !== undefined && q.entry.head < head) {
|
|
1018
|
+
await this.setInput(q.id, { status: "unanswered", reason: "stale" });
|
|
1019
|
+
this.changes.events.push({ conversationId, event: { type: "input.aborted", input: q.id } });
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const eventStart = this.changes.events.length;
|
|
1023
|
+
const entry = this.appendEntryInternal(conversationId, q.entry);
|
|
1024
|
+
const entryEvents = this.changes.events.splice(eventStart);
|
|
1025
|
+
if (q.entry.head !== undefined)
|
|
1026
|
+
head = q.entry.head === "self" ? entry : q.entry.head;
|
|
1027
|
+
await this.setInput(q.id, { status: "done", entry });
|
|
1028
|
+
this.changes.events.push({ conversationId, event: { type: "input.placed", input: q.id, entry } }, ...entryEvents);
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
const eventStart = this.changes.events.length;
|
|
1032
|
+
const entry = this.appendEntryInternal(conversationId, {
|
|
1033
|
+
kind: "pi.user",
|
|
1034
|
+
model: [{ role: "user", content: q.input, timestamp: this.now() }],
|
|
1035
|
+
});
|
|
1036
|
+
const entryEvents = this.changes.events.splice(eventStart);
|
|
1037
|
+
await this.setInput(q.id, { status: "placed", entry });
|
|
1038
|
+
this.changes.events.push({ conversationId, event: { type: "input.placed", input: q.id, entry } }, ...entryEvents);
|
|
1039
|
+
triggers.push(q.id);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
removeWhere(s.inbox, (q) => selected.has(q.id));
|
|
1043
|
+
return { triggers, terminated: cut !== undefined };
|
|
1044
|
+
}
|
|
1045
|
+
// --- finish -------------------------------------------------------------
|
|
1046
|
+
finish() {
|
|
1047
|
+
if (this.poisoned !== undefined)
|
|
1048
|
+
throw this.poisoned;
|
|
1049
|
+
for (const [conversationId, keys] of this.changedConfig) {
|
|
1050
|
+
this.changes.events.push({ conversationId, event: { type: "config.changed", keys: [...keys] } });
|
|
1051
|
+
}
|
|
1052
|
+
for (const { ref, tracker } of this.touched.values()) {
|
|
1053
|
+
const ops = tracker.flush();
|
|
1054
|
+
if (ops.length === 0)
|
|
1055
|
+
continue;
|
|
1056
|
+
this.writes.push({ type: "doc", ref, ops });
|
|
1057
|
+
this.changes.docs.push({ ref, ops });
|
|
1058
|
+
this.docs.noteOps(ref, ops);
|
|
1059
|
+
}
|
|
1060
|
+
return this.writes;
|
|
1061
|
+
}
|
|
1062
|
+
/** Every wrapper handed out by this transaction throws from now on. */
|
|
1063
|
+
revoke() {
|
|
1064
|
+
this.membrane.revoke();
|
|
1065
|
+
}
|
|
1066
|
+
evictTouched() {
|
|
1067
|
+
for (const { ref } of this.touched.values())
|
|
1068
|
+
this.docs.evict(ref);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
/** A plain JSON copy: no document proxy, no live reference, strict JSON (throws on cycles/BigInt). */
|
|
1072
|
+
const plain = (v) => JSON.parse(JSON.stringify(v));
|
|
1073
|
+
function validateEntry(e) {
|
|
1074
|
+
if (e.head !== undefined && e.head > e.id)
|
|
1075
|
+
throw new Error(`entry ${e.id}: head ${e.head} is in the future`);
|
|
1076
|
+
if (e.model !== undefined)
|
|
1077
|
+
for (const m of e.model)
|
|
1078
|
+
if (typeof m !== "object" || m === null || typeof m.role !== "string")
|
|
1079
|
+
throw new Error(`entry ${e.id}: malformed model message`);
|
|
1080
|
+
JSON.stringify(e); // strict JSON: throws on BigInt/cycles; undefined is dropped by construction
|
|
1081
|
+
}
|
|
1082
|
+
// ---------------------------------------------------------------------------
|
|
1083
|
+
// Session: the line.
|
|
1084
|
+
// ---------------------------------------------------------------------------
|
|
1085
|
+
const LINE_KEY = createContextKey("pico3.session.line");
|
|
1086
|
+
const owners = new WeakMap();
|
|
1087
|
+
export class NestedLineOperation extends Error {
|
|
1088
|
+
constructor() {
|
|
1089
|
+
super("nested line operation");
|
|
1090
|
+
this.name = "NestedLineOperation";
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
export class Session {
|
|
1094
|
+
storage;
|
|
1095
|
+
kinds;
|
|
1096
|
+
namespaces;
|
|
1097
|
+
tail = Promise.resolve();
|
|
1098
|
+
closed = false;
|
|
1099
|
+
fault;
|
|
1100
|
+
now;
|
|
1101
|
+
docs;
|
|
1102
|
+
defaults;
|
|
1103
|
+
liveTasks = new Map();
|
|
1104
|
+
/** Owner/parent graph, loaded at open and maintained on every commit. */
|
|
1105
|
+
conversationRecords = new Map();
|
|
1106
|
+
lineListeners = new Set();
|
|
1107
|
+
listeners = new Set();
|
|
1108
|
+
/** Errors from listeners and other post-commit work; never surface to the writer. */
|
|
1109
|
+
onReport = () => { };
|
|
1110
|
+
constructor(storage, kinds, namespaces, now = Date.now) {
|
|
1111
|
+
this.storage = storage;
|
|
1112
|
+
this.now = now;
|
|
1113
|
+
this.kinds = kinds;
|
|
1114
|
+
this.namespaces = namespaces;
|
|
1115
|
+
if (owners.has(storage))
|
|
1116
|
+
throw new Error("this Storage already has an owning Session");
|
|
1117
|
+
this.defaults = new Defaults(kinds.values());
|
|
1118
|
+
this.docs = new Docs(storage, this.defaults);
|
|
1119
|
+
owners.set(storage, this);
|
|
1120
|
+
}
|
|
1121
|
+
index = {
|
|
1122
|
+
get: (id) => this.conversationRecords.get(id),
|
|
1123
|
+
subtree: (root) => {
|
|
1124
|
+
const out = new Set([root]);
|
|
1125
|
+
let grew = true;
|
|
1126
|
+
while (grew) {
|
|
1127
|
+
grew = false;
|
|
1128
|
+
for (const c of this.conversationRecords.values()) {
|
|
1129
|
+
if (out.has(c.id) || c.owner === undefined)
|
|
1130
|
+
continue;
|
|
1131
|
+
const ownerTask = this.liveTasks.get(c.owner) ?? this.ownerTaskCache.get(c.owner);
|
|
1132
|
+
if (ownerTask && out.has(ownerTask.conversationId)) {
|
|
1133
|
+
out.add(c.id);
|
|
1134
|
+
grew = true;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
},
|
|
1140
|
+
ancestors: (id) => {
|
|
1141
|
+
const chain = [];
|
|
1142
|
+
let c = this.conversationRecords.get(id);
|
|
1143
|
+
while (c?.owner !== undefined) {
|
|
1144
|
+
const t = this.liveTasks.get(c.owner) ?? this.ownerTaskCache.get(c.owner);
|
|
1145
|
+
if (t === undefined)
|
|
1146
|
+
break;
|
|
1147
|
+
chain.unshift(t.conversationId);
|
|
1148
|
+
c = this.conversationRecords.get(t.conversationId);
|
|
1149
|
+
}
|
|
1150
|
+
return chain;
|
|
1151
|
+
},
|
|
1152
|
+
};
|
|
1153
|
+
/** Owner tasks that are terminal but whose conversations still exist (ancestry after reopen). */
|
|
1154
|
+
ownerTaskCache = new Map();
|
|
1155
|
+
async commit(invoker, fn, ctx, opts = {}) {
|
|
1156
|
+
ctx.abortSignal?.throwIfAborted();
|
|
1157
|
+
const authority = Object.freeze({ ...invoker });
|
|
1158
|
+
const result = await this.enter(ctx, async (lineCtx) => {
|
|
1159
|
+
this.assertUsable();
|
|
1160
|
+
lineCtx.abortSignal?.throwIfAborted();
|
|
1161
|
+
if (authority.type === "task") {
|
|
1162
|
+
// A captured runtime cannot write after its invocation returned, after terminalization, or after a mark (run mode).
|
|
1163
|
+
if (!authority.token.alive)
|
|
1164
|
+
throw new Forbidden("commit from a finished invocation");
|
|
1165
|
+
const live = this.liveTasks.get(authority.id);
|
|
1166
|
+
if (live === undefined)
|
|
1167
|
+
throw new Forbidden("commit from a task that is not live");
|
|
1168
|
+
if (authority.mode === "run" && live.abort === true)
|
|
1169
|
+
throw new Forbidden("commit from a marked run invocation");
|
|
1170
|
+
}
|
|
1171
|
+
const tx = new TxImpl(authority, this.storage, this.docs, this.defaults, this.kinds, this.namespaces, this.liveTasks, this.index, this.now, lineCtx);
|
|
1172
|
+
tx.closing = opts.closing === true;
|
|
1173
|
+
const refs = [{ doc: "session" }, ...(opts.docs ?? [])];
|
|
1174
|
+
if (authority.type === "task")
|
|
1175
|
+
refs.push({ doc: "rewindable", conversationId: authority.conversationId }, { doc: "sticky", conversationId: authority.conversationId });
|
|
1176
|
+
let persisted = false;
|
|
1177
|
+
try {
|
|
1178
|
+
await tx.preload(refs);
|
|
1179
|
+
let value;
|
|
1180
|
+
try {
|
|
1181
|
+
value = await fn(tx.callbackSurface(), lineCtx, {
|
|
1182
|
+
setTask: (task) => tx.setTaskInternalForControl(task),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
finally {
|
|
1186
|
+
tx.closeSurface();
|
|
1187
|
+
}
|
|
1188
|
+
await tx.preload([...new Set(tx.changes.tasks.map((task) => task.conversationId))].map((conversationId) => ({
|
|
1189
|
+
doc: "sticky",
|
|
1190
|
+
conversationId,
|
|
1191
|
+
})));
|
|
1192
|
+
const writes = tx.finish();
|
|
1193
|
+
let seq;
|
|
1194
|
+
if (writes.length > 0 || tx.changes.events.length > 0) {
|
|
1195
|
+
persisted = true;
|
|
1196
|
+
try {
|
|
1197
|
+
seq = await this.storage.commit(writes, withoutAbortSignal(lineCtx));
|
|
1198
|
+
}
|
|
1199
|
+
catch (error) {
|
|
1200
|
+
this.fault = new Faulted(error);
|
|
1201
|
+
try {
|
|
1202
|
+
await this.storage.close(withoutAbortSignal(lineCtx));
|
|
1203
|
+
owners.delete(this.storage);
|
|
1204
|
+
}
|
|
1205
|
+
catch { }
|
|
1206
|
+
throw this.fault;
|
|
1207
|
+
}
|
|
1208
|
+
this.applyChanges(tx.changes);
|
|
1209
|
+
}
|
|
1210
|
+
const result = { value, seq, changes: tx.changes };
|
|
1211
|
+
if (seq !== undefined) {
|
|
1212
|
+
for (const listener of this.lineListeners) {
|
|
1213
|
+
try {
|
|
1214
|
+
listener(result);
|
|
1215
|
+
}
|
|
1216
|
+
catch (error) {
|
|
1217
|
+
try {
|
|
1218
|
+
this.onReport(error);
|
|
1219
|
+
}
|
|
1220
|
+
catch { }
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return result;
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
if (!persisted)
|
|
1228
|
+
tx.evictTouched();
|
|
1229
|
+
throw error;
|
|
1230
|
+
}
|
|
1231
|
+
finally {
|
|
1232
|
+
tx.revoke();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
if (result.seq !== undefined)
|
|
1236
|
+
for (const l of this.listeners) {
|
|
1237
|
+
try {
|
|
1238
|
+
l(result);
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
this.onReport(error);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
return result;
|
|
1245
|
+
}
|
|
1246
|
+
/** Read-only line operation without a transaction. */
|
|
1247
|
+
read(fn, ctx) {
|
|
1248
|
+
return this.enter(ctx, (lineCtx) => {
|
|
1249
|
+
this.assertUsable();
|
|
1250
|
+
return fn(this.storage, lineCtx);
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
/** Generic line operation (waiter registration and asynchronous lifecycle work). */
|
|
1254
|
+
onLine(fn, ctx) {
|
|
1255
|
+
return this.enter(ctx, (lineCtx) => {
|
|
1256
|
+
this.assertUsable();
|
|
1257
|
+
return fn(lineCtx);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
static STICKY_BASE_BUDGET = 256 * 1024;
|
|
1261
|
+
/** After a task terminalizes: retire its slot; base + truncate when idle or over budget. Truncation runs on the line. */
|
|
1262
|
+
async retire(task, ctx) {
|
|
1263
|
+
const ref = { doc: "sticky", conversationId: task.conversationId };
|
|
1264
|
+
const idle = ![...this.liveTasks.values()].some((t) => t.conversationId === task.conversationId);
|
|
1265
|
+
const over = (this.docs.sinceBase.get(docKey(ref)) ?? 0) > Session.STICKY_BASE_BUDGET;
|
|
1266
|
+
await this.commit({ type: "kernel" }, (tx) => {
|
|
1267
|
+
delete tx.sticky(task.conversationId).tasks[task.id];
|
|
1268
|
+
if (idle || over)
|
|
1269
|
+
this.docs.requestBase(ref);
|
|
1270
|
+
}, ctx, { docs: [ref] });
|
|
1271
|
+
if (idle || over)
|
|
1272
|
+
await this.enter(ctx, (lineCtx) => this.storage.truncate(ref, withoutAbortSignal(lineCtx)));
|
|
1273
|
+
}
|
|
1274
|
+
async fork(parentId, at, spec, ctx) {
|
|
1275
|
+
const inherited = at === "start"
|
|
1276
|
+
? undefined
|
|
1277
|
+
: await this.read(async (storage, lineCtx) => {
|
|
1278
|
+
const [entry] = await storage.scanEntries({ conversationId: parentId, before: at + 1, limit: 1 }, lineCtx);
|
|
1279
|
+
if (entry?.id !== at)
|
|
1280
|
+
throw new Error(`entry ${at} is not visible from conversation ${parentId}`);
|
|
1281
|
+
return storage.docAsOf(parentId, at, lineCtx);
|
|
1282
|
+
}, ctx);
|
|
1283
|
+
const rawOverrides = structuredClone(spec.rewindable ?? {});
|
|
1284
|
+
delete rawOverrides.plugins;
|
|
1285
|
+
const overrides = this.defaults.validateSeed("rewindable", rawOverrides);
|
|
1286
|
+
const rewindable = inherited === undefined
|
|
1287
|
+
? overrides
|
|
1288
|
+
: { ...inherited, ...overrides, plugins: structuredClone(inherited.plugins) };
|
|
1289
|
+
const r = await this.commit({ type: "kernel" }, (tx) => tx.createForkConversation({
|
|
1290
|
+
parent: { conversationId: parentId, at },
|
|
1291
|
+
rewindable,
|
|
1292
|
+
sticky: spec.sticky,
|
|
1293
|
+
sections: spec.sections,
|
|
1294
|
+
}), ctx);
|
|
1295
|
+
return r.value;
|
|
1296
|
+
}
|
|
1297
|
+
async close(ctx) {
|
|
1298
|
+
await this.enter(ctx, async (lineCtx) => {
|
|
1299
|
+
if (this.closed)
|
|
1300
|
+
return;
|
|
1301
|
+
await this.storage.close(withoutAbortSignal(lineCtx));
|
|
1302
|
+
this.closed = true;
|
|
1303
|
+
owners.delete(this.storage);
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
loadedDocument(ref) {
|
|
1307
|
+
return this.docs.loaded(ref);
|
|
1308
|
+
}
|
|
1309
|
+
applyChanges(e) {
|
|
1310
|
+
for (const t of e.tasks) {
|
|
1311
|
+
if (t.status === "terminal") {
|
|
1312
|
+
this.liveTasks.delete(t.id);
|
|
1313
|
+
if (t.owns.length)
|
|
1314
|
+
this.ownerTaskCache.set(t.id, t);
|
|
1315
|
+
}
|
|
1316
|
+
else
|
|
1317
|
+
this.liveTasks.set(t.id, t);
|
|
1318
|
+
}
|
|
1319
|
+
for (const c of e.conversations)
|
|
1320
|
+
this.conversationRecords.set(c.id, c);
|
|
1321
|
+
}
|
|
1322
|
+
enter(ctx, op) {
|
|
1323
|
+
if (ctx.value(LINE_KEY) === true)
|
|
1324
|
+
return Promise.reject(new NestedLineOperation());
|
|
1325
|
+
const previous = this.tail;
|
|
1326
|
+
let release;
|
|
1327
|
+
this.tail = new Promise((resolve) => {
|
|
1328
|
+
release = resolve;
|
|
1329
|
+
});
|
|
1330
|
+
return (async () => {
|
|
1331
|
+
await previous;
|
|
1332
|
+
try {
|
|
1333
|
+
return await op(withContextValue(LINE_KEY, true, ctx));
|
|
1334
|
+
}
|
|
1335
|
+
finally {
|
|
1336
|
+
release();
|
|
1337
|
+
}
|
|
1338
|
+
})();
|
|
1339
|
+
}
|
|
1340
|
+
assertUsable() {
|
|
1341
|
+
if (this.fault !== undefined)
|
|
1342
|
+
throw this.fault;
|
|
1343
|
+
if (this.closed)
|
|
1344
|
+
throw new Closed();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
//# sourceMappingURL=session.js.map
|