@fungi.computer/shiitake 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +454 -2
- package/dist/agar-extension.d.ts +9 -0
- package/dist/agar-extension.js +38 -0
- package/dist/agent-control.d.ts +72 -0
- package/dist/agent-control.js +431 -0
- package/dist/client-entry.d.ts +211 -0
- package/dist/client-entry.js +101 -0
- package/dist/client-wire.d.ts +199 -0
- package/dist/client-wire.js +271 -0
- package/dist/client.d.ts +439 -0
- package/dist/client.js +4 -0
- package/dist/cloudflare.d.ts +220 -0
- package/dist/cloudflare.js +531 -0
- package/dist/effect.d.ts +42 -0
- package/dist/effect.js +20 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.js +81 -0
- package/dist/extension-client.d.ts +38 -0
- package/dist/extension-client.js +56 -0
- package/dist/extension-contract.d.ts +73 -0
- package/dist/extension-contract.js +11 -0
- package/dist/extensions.d.ts +589 -0
- package/dist/extensions.js +263 -0
- package/dist/fleet.d.ts +297 -0
- package/dist/fleet.js +515 -0
- package/dist/hosted.d.ts +133 -0
- package/dist/hosted.js +265 -0
- package/dist/http-routes.d.ts +772 -0
- package/dist/http-routes.js +242 -0
- package/dist/http.d.ts +1 -0
- package/dist/http.js +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +13 -0
- package/dist/internal/accepted-run-executor.d.ts +23 -0
- package/dist/internal/accepted-run-executor.js +129 -0
- package/dist/internal/admission-door.d.ts +75 -0
- package/dist/internal/admission-door.js +105 -0
- package/dist/internal/agent-close.d.ts +4 -0
- package/dist/internal/agent-close.js +42 -0
- package/dist/internal/browser-sessions.d.ts +31 -0
- package/dist/internal/browser-sessions.js +173 -0
- package/dist/internal/client-boundary.d.ts +262 -0
- package/dist/internal/client-boundary.js +202 -0
- package/dist/internal/compaction/compaction.d.ts +54 -0
- package/dist/internal/compaction/compaction.js +292 -0
- package/dist/internal/compaction/session-compaction.d.ts +69 -0
- package/dist/internal/compaction/session-compaction.js +330 -0
- package/dist/internal/compaction/settings.d.ts +11 -0
- package/dist/internal/compaction/settings.js +40 -0
- package/dist/internal/compaction/utils.d.ts +45 -0
- package/dist/internal/compaction/utils.js +319 -0
- package/dist/internal/contracts.d.ts +235 -0
- package/dist/internal/contracts.js +1 -0
- package/dist/internal/coordinator.d.ts +18 -0
- package/dist/internal/coordinator.js +362 -0
- package/dist/internal/document-codec.d.ts +11 -0
- package/dist/internal/document-codec.js +39 -0
- package/dist/internal/effect-client.d.ts +13 -0
- package/dist/internal/effect-client.js +102 -0
- package/dist/internal/effect-contracts.d.ts +236 -0
- package/dist/internal/effect-contracts.js +2 -0
- package/dist/internal/extension-command-registry.d.ts +11 -0
- package/dist/internal/extension-command-registry.js +29 -0
- package/dist/internal/extension-command-tools.d.ts +17 -0
- package/dist/internal/extension-command-tools.js +50 -0
- package/dist/internal/extension-context.d.ts +25 -0
- package/dist/internal/extension-context.js +35 -0
- package/dist/internal/extension-definitions.d.ts +5 -0
- package/dist/internal/extension-definitions.js +6 -0
- package/dist/internal/extension-hook.d.ts +25 -0
- package/dist/internal/extension-hook.js +46 -0
- package/dist/internal/extension-roster.d.ts +23 -0
- package/dist/internal/extension-roster.js +228 -0
- package/dist/internal/extension-runtime.d.ts +63 -0
- package/dist/internal/extension-runtime.js +1004 -0
- package/dist/internal/failure.d.ts +2 -0
- package/dist/internal/failure.js +9 -0
- package/dist/internal/host-meta.d.ts +41 -0
- package/dist/internal/host-meta.js +39 -0
- package/dist/internal/json-value.d.ts +4 -0
- package/dist/internal/json-value.js +3 -0
- package/dist/internal/model-acquisition.d.ts +21 -0
- package/dist/internal/model-acquisition.js +98 -0
- package/dist/internal/model-preference.d.ts +21 -0
- package/dist/internal/model-preference.js +20 -0
- package/dist/internal/observation-kernel.d.ts +28 -0
- package/dist/internal/observation-kernel.js +169 -0
- package/dist/internal/promise-facade.d.ts +18 -0
- package/dist/internal/promise-facade.js +172 -0
- package/dist/internal/run-scope.d.ts +27 -0
- package/dist/internal/run-scope.js +214 -0
- package/dist/internal/runtime-coordinator.d.ts +34 -0
- package/dist/internal/runtime-coordinator.js +233 -0
- package/dist/internal/runtime-core.d.ts +19 -0
- package/dist/internal/runtime-core.js +39 -0
- package/dist/internal/runtime-reconciliation.d.ts +18 -0
- package/dist/internal/runtime-reconciliation.js +162 -0
- package/dist/internal/session-context.d.ts +11 -0
- package/dist/internal/session-context.js +67 -0
- package/dist/internal/session-status.d.ts +10 -0
- package/dist/internal/session-status.js +52 -0
- package/dist/internal/woodstock/agent-reply.d.ts +25 -0
- package/dist/internal/woodstock/agent-reply.js +67 -0
- package/dist/internal/woodstock/client-projection.d.ts +29 -0
- package/dist/internal/woodstock/client-projection.js +512 -0
- package/dist/internal/woodstock/compaction-activity.d.ts +17 -0
- package/dist/internal/woodstock/compaction-activity.js +32 -0
- package/dist/internal/woodstock/cursor.d.ts +9 -0
- package/dist/internal/woodstock/cursor.js +44 -0
- package/dist/internal/woodstock/extension-store.d.ts +72 -0
- package/dist/internal/woodstock/extension-store.js +262 -0
- package/dist/internal/woodstock/fleet-store.d.ts +32 -0
- package/dist/internal/woodstock/fleet-store.js +93 -0
- package/dist/internal/woodstock/identities.d.ts +6 -0
- package/dist/internal/woodstock/identities.js +11 -0
- package/dist/internal/woodstock/index.d.ts +170 -0
- package/dist/internal/woodstock/index.js +546 -0
- package/dist/internal/woodstock/progress.d.ts +32 -0
- package/dist/internal/woodstock/progress.js +89 -0
- package/dist/internal/woodstock/run-recovery.d.ts +8 -0
- package/dist/internal/woodstock/run-recovery.js +95 -0
- package/dist/internal/woodstock/session-attention.d.ts +16 -0
- package/dist/internal/woodstock/session-attention.js +88 -0
- package/dist/internal/woodstock/session-documents.d.ts +18 -0
- package/dist/internal/woodstock/session-documents.js +51 -0
- package/dist/internal/woodstock/session-entry.d.ts +478 -0
- package/dist/internal/woodstock/session-entry.js +484 -0
- package/dist/internal/woodstock/session-grants.d.ts +24 -0
- package/dist/internal/woodstock/session-grants.js +84 -0
- package/dist/internal/woodstock/session-inbox.d.ts +56 -0
- package/dist/internal/woodstock/session-inbox.js +307 -0
- package/dist/internal/woodstock/session-store.d.ts +432 -0
- package/dist/internal/woodstock/session-store.js +2006 -0
- package/dist/internal/woodstock/storage-admission.d.ts +14 -0
- package/dist/internal/woodstock/storage-admission.js +44 -0
- package/dist/model-preference.d.ts +20 -0
- package/dist/model-preference.js +30 -0
- package/dist/node.d.ts +64 -0
- package/dist/node.js +256 -0
- package/dist/remote-client.d.ts +11 -0
- package/dist/remote-client.js +298 -0
- package/dist/session-attention.d.ts +35 -0
- package/dist/session-attention.js +22 -0
- package/dist/session-documents.d.ts +28 -0
- package/dist/session-documents.js +1 -0
- package/dist/testing.d.ts +54 -0
- package/dist/testing.js +215 -0
- package/docs/api/README.md +26 -0
- package/docs/api/agar-extension.md +40 -0
- package/docs/api/agent-control.md +151 -0
- package/docs/api/client-entry.md +344 -0
- package/docs/api/client.md +1112 -0
- package/docs/api/cloudflare.md +500 -0
- package/docs/api/effect.md +904 -0
- package/docs/api/extensions.md +2003 -0
- package/docs/api/fleet.md +706 -0
- package/docs/api/hosted.md +382 -0
- package/docs/api/http.md +111 -0
- package/docs/api/index.md +1116 -0
- package/docs/api/model-preference.md +151 -0
- package/docs/api/node.md +142 -0
- package/docs/api/testing.md +250 -0
- package/package.json +130 -3
|
@@ -0,0 +1,2006 @@
|
|
|
1
|
+
import { prepareExtensionStore } from "./extension-store.js";
|
|
2
|
+
import { prepareSessionDocuments, forkDocumentEntries, } from "./session-documents.js";
|
|
3
|
+
import * as Schema from "effect/Schema";
|
|
4
|
+
import { sameHostMeta } from "../host-meta.js";
|
|
5
|
+
import { ShiitakeError } from "../../errors.js";
|
|
6
|
+
import { compactionSummaryMessage } from "../compaction/utils.js";
|
|
7
|
+
import { SessionTitleSchema, sessionTitleLimit } from "../client-boundary.js";
|
|
8
|
+
import { commandTimestamp, decodeStoredSessionEntry, encodeStoredSessionEntry, isCompactCommand, commandContextMessage, operatorContextMessage, promptCommand, } from "./session-entry.js";
|
|
9
|
+
import { prepareSessionAttention } from "./session-attention.js";
|
|
10
|
+
import { copySessionGrants, prepareSessionGrants } from "./session-grants.js";
|
|
11
|
+
import { prepareSessionInbox, touchSessionInbox } from "./session-inbox.js";
|
|
12
|
+
import { sessionIds } from "./identities.js";
|
|
13
|
+
import { recoverAssistantTurn, abnormalTerminal, contextMessageIds, } from "./run-recovery.js";
|
|
14
|
+
const closed = { onExcessProperty: "error" };
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const boundedId = Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.makeFilter((value) => encoder.encode(value).byteLength <= 256)));
|
|
17
|
+
const sequence = Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0)));
|
|
18
|
+
const timestamp = Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)));
|
|
19
|
+
const SessionRowSchema = Schema.Struct({
|
|
20
|
+
id: boundedId,
|
|
21
|
+
leaf_id: Schema.NullOr(boundedId),
|
|
22
|
+
});
|
|
23
|
+
const SessionCreationReceiptRowSchema = Schema.Struct({
|
|
24
|
+
session_id: boundedId,
|
|
25
|
+
});
|
|
26
|
+
const SessionSummaryRowSchema = Schema.Struct({
|
|
27
|
+
id: boundedId,
|
|
28
|
+
leaf_id: Schema.NullOr(boundedId),
|
|
29
|
+
created_at: timestamp,
|
|
30
|
+
updated_at: timestamp,
|
|
31
|
+
metadata_json: Schema.NullOr(Schema.String),
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* The Session row's display metadata: the user's title, which overrides the
|
|
35
|
+
* derived first-prompt title. `NULL` means no user title.
|
|
36
|
+
*/
|
|
37
|
+
const SessionMetadataSchema = Schema.fromJsonString(Schema.Struct({ title: SessionTitleSchema }));
|
|
38
|
+
const decodeSessionMetadata = Schema.decodeUnknownResult(SessionMetadataSchema, closed);
|
|
39
|
+
const encodeSessionMetadata = Schema.encodeSync(SessionMetadataSchema);
|
|
40
|
+
const SessionIdRowSchema = Schema.Struct({ id: boundedId });
|
|
41
|
+
const WoodstockSchemaVersionRowSchema = Schema.Struct({
|
|
42
|
+
version: Schema.Int,
|
|
43
|
+
protocol: Schema.Literal("shiitake.extension/v0"),
|
|
44
|
+
});
|
|
45
|
+
const TableNameRowSchema = Schema.Struct({ name: boundedId });
|
|
46
|
+
const EntryRowSchema = Schema.Struct({
|
|
47
|
+
id: boundedId,
|
|
48
|
+
session_id: boundedId,
|
|
49
|
+
parent_id: Schema.NullOr(boundedId),
|
|
50
|
+
sequence,
|
|
51
|
+
type: Schema.String,
|
|
52
|
+
role: Schema.NullOr(Schema.Literals(["user", "assistant", "tool_result"])),
|
|
53
|
+
state: Schema.Literals(["complete", "streaming"]),
|
|
54
|
+
request_id: Schema.NullOr(boundedId),
|
|
55
|
+
work_id: Schema.NullOr(boundedId),
|
|
56
|
+
data_json: Schema.String,
|
|
57
|
+
timestamp,
|
|
58
|
+
completed_at: Schema.NullOr(timestamp),
|
|
59
|
+
});
|
|
60
|
+
const CommandAdmissionRowSchema = Schema.Struct({
|
|
61
|
+
id: boundedId,
|
|
62
|
+
session_id: boundedId,
|
|
63
|
+
sequence,
|
|
64
|
+
request_id: boundedId,
|
|
65
|
+
work_id: boundedId,
|
|
66
|
+
data_json: Schema.String,
|
|
67
|
+
timestamp,
|
|
68
|
+
origin_kind: Schema.Literals(["user", "extension"]),
|
|
69
|
+
origin_id: Schema.NullOr(boundedId),
|
|
70
|
+
origin_correlation: Schema.NullOr(boundedId),
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* Who admitted one accepted command: a human, host or addressed caller
|
|
74
|
+
* (`user`), or one rostered extension with its correlation.
|
|
75
|
+
*/
|
|
76
|
+
export const ExtensionAdmissionOriginSchema = Schema.Struct({
|
|
77
|
+
kind: Schema.Literal("extension"),
|
|
78
|
+
extensionId: boundedId,
|
|
79
|
+
correlation: Schema.NullOr(boundedId),
|
|
80
|
+
});
|
|
81
|
+
export const AdmissionOriginSchema = Schema.Union([
|
|
82
|
+
Schema.Struct({ kind: Schema.Literal("user") }),
|
|
83
|
+
ExtensionAdmissionOriginSchema,
|
|
84
|
+
]);
|
|
85
|
+
const decodeAdmissionOrigin = Schema.decodeUnknownResult(AdmissionOriginSchema, closed);
|
|
86
|
+
const equivalentAdmissionOrigin = Schema.toEquivalence(AdmissionOriginSchema);
|
|
87
|
+
const admissionColumns = (alias) => [
|
|
88
|
+
"id",
|
|
89
|
+
"session_id",
|
|
90
|
+
"sequence",
|
|
91
|
+
"request_id",
|
|
92
|
+
"work_id",
|
|
93
|
+
"data_json",
|
|
94
|
+
"timestamp",
|
|
95
|
+
"origin_kind",
|
|
96
|
+
"origin_id",
|
|
97
|
+
"origin_correlation",
|
|
98
|
+
]
|
|
99
|
+
.map((column) => alias + column)
|
|
100
|
+
.join(", ");
|
|
101
|
+
const NextSequenceRowSchema = Schema.Struct({ next_sequence: sequence });
|
|
102
|
+
const EntryPresenceRowSchema = Schema.Struct({ present: Schema.Literal(1) });
|
|
103
|
+
const decodeSessionRow = Schema.decodeUnknownResult(SessionRowSchema, closed);
|
|
104
|
+
const decodeSessionCreationReceiptRow = Schema.decodeUnknownResult(SessionCreationReceiptRowSchema, closed);
|
|
105
|
+
const decodeSessionSummaryRow = Schema.decodeUnknownResult(SessionSummaryRowSchema, closed);
|
|
106
|
+
const decodeSessionIdRow = Schema.decodeUnknownResult(SessionIdRowSchema, closed);
|
|
107
|
+
const decodeWoodstockSchemaVersionRow = Schema.decodeUnknownResult(WoodstockSchemaVersionRowSchema, closed);
|
|
108
|
+
const decodeTableNameRow = Schema.decodeUnknownResult(TableNameRowSchema, closed);
|
|
109
|
+
const SummaryLineageSchema = Schema.Struct({
|
|
110
|
+
candidate_id: Schema.NullOr(boundedId),
|
|
111
|
+
path_count: Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))),
|
|
112
|
+
root_count: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
113
|
+
missing_parent_count: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))),
|
|
114
|
+
leaf_state: Schema.Literals(["complete", "streaming"]),
|
|
115
|
+
});
|
|
116
|
+
const decodeSummaryLineage = Schema.decodeUnknownResult(SummaryLineageSchema, closed);
|
|
117
|
+
const decodeEntryRow = Schema.decodeUnknownResult(EntryRowSchema, closed);
|
|
118
|
+
const decodeCommandAdmissionRow = Schema.decodeUnknownResult(CommandAdmissionRowSchema, closed);
|
|
119
|
+
const decodeModelChoiceRow = Schema.decodeUnknownResult(Schema.Struct({ sequence: Schema.NullOr(sequence) }), closed);
|
|
120
|
+
const decodeNextSequenceRow = Schema.decodeUnknownResult(NextSequenceRowSchema, closed);
|
|
121
|
+
const decodeEntryPresenceRow = Schema.decodeUnknownResult(EntryPresenceRowSchema, closed);
|
|
122
|
+
/**
|
|
123
|
+
* Page stored conversation facts without creating Sessions or opening hosts.
|
|
124
|
+
*
|
|
125
|
+
* A Session whose stored facts cannot be read becomes its own `unavailable`
|
|
126
|
+
* row, so one damaged conversation never hides the others. Run status is not
|
|
127
|
+
* stored here; only the Session's owner can report it.
|
|
128
|
+
*/
|
|
129
|
+
/** Attach host metadata to an entry only when the host supplied some. */
|
|
130
|
+
function withHostMeta(entry, meta) {
|
|
131
|
+
if (meta === undefined)
|
|
132
|
+
return entry;
|
|
133
|
+
return { ...entry, meta };
|
|
134
|
+
}
|
|
135
|
+
export function readStoredSessionFactPage(owner, after) {
|
|
136
|
+
// This is a read-page budget, never a limit on the number of saved Sessions.
|
|
137
|
+
const pageSize = 200;
|
|
138
|
+
const rows = owner.sql
|
|
139
|
+
.exec("SELECT id FROM sessions WHERE (? IS NULL OR id > ?) ORDER BY id LIMIT ?", after ?? null, after ?? null, pageSize)
|
|
140
|
+
.toArray();
|
|
141
|
+
const sessions = [];
|
|
142
|
+
let last;
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
const decoded = decodeSessionIdRow(row);
|
|
145
|
+
// Without a valid identity the row cannot be named or paged past.
|
|
146
|
+
if (decoded._tag === "Failure")
|
|
147
|
+
throw unavailable("Stored Session identity is invalid");
|
|
148
|
+
const sessionId = decoded.success.id;
|
|
149
|
+
last = sessionId;
|
|
150
|
+
try {
|
|
151
|
+
const summary = readStoredSessionSummary(owner, sessionId);
|
|
152
|
+
if (summary !== undefined)
|
|
153
|
+
sessions.push(Object.freeze({ kind: "session", sessionId, ...summary }));
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
// Only a typed store/status unavailability is row-local.
|
|
157
|
+
if (!(error instanceof ShiitakeError) || error.cause !== "unavailable")
|
|
158
|
+
throw error;
|
|
159
|
+
sessions.push(Object.freeze({ kind: "unavailable", sessionId }));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { sessions, next: rows.length === pageSize ? last : undefined };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Read one persisted Session's stored title facts without opening its host.
|
|
166
|
+
* This is the one title read: the user's title when set, otherwise the
|
|
167
|
+
* title derived from the first entered prompt.
|
|
168
|
+
*/
|
|
169
|
+
export function readStoredSessionSummary(owner, sessionId) {
|
|
170
|
+
const raw = owner.sql
|
|
171
|
+
.exec(`SELECT id, leaf_id, created_at, updated_at, metadata_json
|
|
172
|
+
FROM sessions
|
|
173
|
+
WHERE id = ?`, sessionId)
|
|
174
|
+
.toArray()[0];
|
|
175
|
+
if (raw === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
const decoded = decodeSessionSummaryRow(raw);
|
|
178
|
+
if (decoded._tag === "Failure")
|
|
179
|
+
throw unavailable("Stored Session summary row is invalid");
|
|
180
|
+
const derived = readDerivedSessionSummary(owner, sessionId, decoded.success);
|
|
181
|
+
const metadata = decoded.success.metadata_json;
|
|
182
|
+
if (metadata === null)
|
|
183
|
+
return derived;
|
|
184
|
+
const user = decodeSessionMetadata(metadata);
|
|
185
|
+
if (user._tag === "Failure")
|
|
186
|
+
throw unavailable("Stored Session title is invalid");
|
|
187
|
+
return Object.freeze({
|
|
188
|
+
createdAt: decoded.success.created_at,
|
|
189
|
+
updatedAt: decoded.success.updated_at,
|
|
190
|
+
title: user.success.title,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const decodeFirstPromptRow = Schema.decodeUnknownResult(Schema.Struct({ id: boundedId, sequence }), closed);
|
|
194
|
+
/**
|
|
195
|
+
* The selected lineage's first delivered prompt, by bounded indexed reads.
|
|
196
|
+
*
|
|
197
|
+
* The Session's earliest delivered prompt is found in sequence order. It is
|
|
198
|
+
* on the selected lineage when the leaf is at or after it and no entry
|
|
199
|
+
* before it has two children: the history before it is then one chain, so
|
|
200
|
+
* every later leaf descends through it. Only a branch taken before the
|
|
201
|
+
* first prompt needs the selected lineage walked.
|
|
202
|
+
*/
|
|
203
|
+
function firstPromptId(owner, sessionId, leafId, leafSequence) {
|
|
204
|
+
const raw = owner.sql
|
|
205
|
+
.exec(`SELECT command.id AS id, command.sequence AS sequence
|
|
206
|
+
FROM session_entries AS command
|
|
207
|
+
WHERE command.session_id = ? AND command.type = 'command'
|
|
208
|
+
AND command.role = 'user' AND command.state = 'complete'
|
|
209
|
+
AND NOT EXISTS (
|
|
210
|
+
SELECT 1 FROM session_entries AS cancelled
|
|
211
|
+
WHERE cancelled.work_id = command.work_id
|
|
212
|
+
AND cancelled.session_id = command.session_id
|
|
213
|
+
AND cancelled.type = 'cancellation'
|
|
214
|
+
AND cancelled.sequence < command.sequence
|
|
215
|
+
)
|
|
216
|
+
ORDER BY command.sequence ASC LIMIT 1`, sessionId)
|
|
217
|
+
.toArray()[0];
|
|
218
|
+
if (raw === undefined)
|
|
219
|
+
return null;
|
|
220
|
+
const decoded = decodeFirstPromptRow(raw);
|
|
221
|
+
if (decoded._tag === "Failure")
|
|
222
|
+
throw unavailable("Stored Session first prompt is invalid");
|
|
223
|
+
const earliest = decoded.success;
|
|
224
|
+
if (leafSequence < earliest.sequence)
|
|
225
|
+
return null;
|
|
226
|
+
const branched = owner.sql
|
|
227
|
+
.exec(`SELECT 1 AS present FROM session_entries AS parent
|
|
228
|
+
WHERE parent.session_id = ? AND parent.sequence < ?
|
|
229
|
+
AND (SELECT COUNT(*) FROM session_entries AS child
|
|
230
|
+
WHERE child.parent_id = parent.id
|
|
231
|
+
AND child.session_id = parent.session_id
|
|
232
|
+
AND child.state = 'complete') > 1
|
|
233
|
+
LIMIT 1`, sessionId, earliest.sequence)
|
|
234
|
+
.toArray()[0];
|
|
235
|
+
return branched === undefined
|
|
236
|
+
? earliest.id
|
|
237
|
+
: selectedLineageFirstPromptId(owner, sessionId, leafId);
|
|
238
|
+
}
|
|
239
|
+
/** The first delivered prompt of a lineage that branched before it. */
|
|
240
|
+
function selectedLineageFirstPromptId(owner, sessionId, leafId) {
|
|
241
|
+
const lineage = owner.sql
|
|
242
|
+
.exec(`WITH RECURSIVE branch (id, parent_id, sequence, type, role, state, work_id) AS (
|
|
243
|
+
SELECT id, parent_id, sequence, type, role, state, work_id
|
|
244
|
+
FROM session_entries
|
|
245
|
+
WHERE session_id = ? AND id = ?
|
|
246
|
+
UNION
|
|
247
|
+
SELECT parent.id, parent.parent_id, parent.sequence,
|
|
248
|
+
parent.type, parent.role, parent.state, parent.work_id
|
|
249
|
+
FROM session_entries AS parent
|
|
250
|
+
JOIN branch AS child ON parent.id = child.parent_id
|
|
251
|
+
WHERE parent.session_id = ?
|
|
252
|
+
)
|
|
253
|
+
SELECT
|
|
254
|
+
(SELECT command.id FROM branch AS command
|
|
255
|
+
WHERE command.type = 'command' AND command.role = 'user'
|
|
256
|
+
AND command.state = 'complete'
|
|
257
|
+
AND NOT EXISTS (
|
|
258
|
+
SELECT 1 FROM branch AS cancelled
|
|
259
|
+
WHERE cancelled.type = 'cancellation'
|
|
260
|
+
AND cancelled.work_id = command.work_id
|
|
261
|
+
AND cancelled.sequence < command.sequence
|
|
262
|
+
)
|
|
263
|
+
ORDER BY command.sequence ASC LIMIT 1) AS candidate_id,
|
|
264
|
+
(SELECT COUNT(*) FROM branch) AS path_count,
|
|
265
|
+
(SELECT COUNT(*) FROM branch WHERE parent_id IS NULL) AS root_count,
|
|
266
|
+
(SELECT COUNT(*) FROM branch AS child
|
|
267
|
+
WHERE child.parent_id IS NOT NULL
|
|
268
|
+
AND NOT EXISTS (
|
|
269
|
+
SELECT 1 FROM session_entries AS parent
|
|
270
|
+
WHERE parent.session_id = ? AND parent.id = child.parent_id
|
|
271
|
+
)) AS missing_parent_count,
|
|
272
|
+
(SELECT state FROM branch WHERE id = ?) AS leaf_state`, sessionId, leafId, sessionId, sessionId, leafId)
|
|
273
|
+
.one();
|
|
274
|
+
const lineageDecoded = decodeSummaryLineage(lineage);
|
|
275
|
+
if (lineageDecoded._tag === "Failure")
|
|
276
|
+
throw unavailable("Stored Session summary lineage is invalid");
|
|
277
|
+
if (lineageDecoded.success.leaf_state !== "complete" ||
|
|
278
|
+
lineageDecoded.success.root_count !== 1 ||
|
|
279
|
+
lineageDecoded.success.missing_parent_count !== 0)
|
|
280
|
+
throw unavailable("Stored Session summary lineage is invalid");
|
|
281
|
+
return lineageDecoded.success.candidate_id;
|
|
282
|
+
}
|
|
283
|
+
/** The first-prompt title the Session shows when the user has set none. */
|
|
284
|
+
function readDerivedSessionSummary(owner, sessionId, row) {
|
|
285
|
+
if (row.leaf_id === null)
|
|
286
|
+
return Object.freeze({
|
|
287
|
+
createdAt: row.created_at,
|
|
288
|
+
updatedAt: row.updated_at,
|
|
289
|
+
title: "",
|
|
290
|
+
});
|
|
291
|
+
const leaf = readEntryRow(owner, sessionId, row.leaf_id);
|
|
292
|
+
if (leaf?.state !== "complete")
|
|
293
|
+
throw unavailable("Stored Session summary leaf is invalid");
|
|
294
|
+
const candidateId = firstPromptId(owner, sessionId, row.leaf_id, leaf.sequence);
|
|
295
|
+
const candidate = candidateId === null
|
|
296
|
+
? undefined
|
|
297
|
+
: readEntryRow(owner, sessionId, candidateId)?.entry;
|
|
298
|
+
const firstPrompt = candidate === undefined ? undefined : promptCommand(candidate);
|
|
299
|
+
if (firstPrompt === undefined)
|
|
300
|
+
return undefined;
|
|
301
|
+
return Object.freeze({
|
|
302
|
+
createdAt: row.created_at,
|
|
303
|
+
updatedAt: row.updated_at,
|
|
304
|
+
title: Array.from(!isPromptParts(firstPrompt.message.content)
|
|
305
|
+
? firstPrompt.message.content
|
|
306
|
+
: firstPrompt.message.content
|
|
307
|
+
.filter((part) => part.type === "text")
|
|
308
|
+
.map((part) => part.text)
|
|
309
|
+
.join("") || "Image prompt")
|
|
310
|
+
.slice(0, sessionTitleLimit)
|
|
311
|
+
.join(""),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/** The inbox title: "" before a first prompt, null when unreadable. */
|
|
315
|
+
function inboxTitle(owner, sessionId) {
|
|
316
|
+
try {
|
|
317
|
+
return readStoredSessionSummary(owner, sessionId)?.title ?? "";
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (error instanceof ShiitakeError && error.cause === "unavailable")
|
|
321
|
+
return null;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function unavailable(message) {
|
|
326
|
+
return new ShiitakeError("store", "unavailable", message);
|
|
327
|
+
}
|
|
328
|
+
function incompatible(message) {
|
|
329
|
+
return new ShiitakeError("store", "incompatible", message);
|
|
330
|
+
}
|
|
331
|
+
function parseSessionRow(raw) {
|
|
332
|
+
const decoded = decodeSessionRow(raw);
|
|
333
|
+
if (decoded._tag === "Failure")
|
|
334
|
+
throw unavailable("Stored Session row is invalid");
|
|
335
|
+
return decoded.success;
|
|
336
|
+
}
|
|
337
|
+
function requestIdFor(entry) {
|
|
338
|
+
return entry.kind === "command" ||
|
|
339
|
+
entry.kind === "cancellation" ||
|
|
340
|
+
entry.kind === "operator"
|
|
341
|
+
? entry.requestId
|
|
342
|
+
: null;
|
|
343
|
+
}
|
|
344
|
+
function roleFor(entry) {
|
|
345
|
+
// A compaction request is accepted work, not a user message.
|
|
346
|
+
if (entry.kind === "command")
|
|
347
|
+
return isCompactCommand(entry) ? null : "user";
|
|
348
|
+
if (entry.kind === "operator")
|
|
349
|
+
return entry.author === "party" ? "user" : "assistant";
|
|
350
|
+
if (entry.kind === "message_start")
|
|
351
|
+
return entry.role === "toolResult" ? "tool_result" : entry.role;
|
|
352
|
+
if (entry.kind !== "message")
|
|
353
|
+
return null;
|
|
354
|
+
return entry.value.role === "toolResult" ? "tool_result" : entry.value.role;
|
|
355
|
+
}
|
|
356
|
+
function sameCausalIdentity(left, right) {
|
|
357
|
+
return (left.kind === right.kind &&
|
|
358
|
+
left.value === right.value &&
|
|
359
|
+
left.parent?.kind === right.parent?.kind &&
|
|
360
|
+
left.parent?.value === right.parent?.value);
|
|
361
|
+
}
|
|
362
|
+
function parseEntryRow(raw) {
|
|
363
|
+
const decodedRow = decodeEntryRow(raw);
|
|
364
|
+
if (decodedRow._tag === "Failure")
|
|
365
|
+
throw unavailable("Stored Session entry row is invalid");
|
|
366
|
+
const row = decodedRow.success;
|
|
367
|
+
const decodedEntry = decodeStoredSessionEntry(row.data_json);
|
|
368
|
+
if (decodedEntry._tag === "Failure")
|
|
369
|
+
throw unavailable("Stored Session entry data is invalid");
|
|
370
|
+
const entry = decodedEntry.success;
|
|
371
|
+
if (row.type !== entry.kind ||
|
|
372
|
+
row.work_id !== entry.workId ||
|
|
373
|
+
row.request_id !== requestIdFor(entry) ||
|
|
374
|
+
row.role !== roleFor(entry))
|
|
375
|
+
throw unavailable("Stored Session entry columns disagree with its data");
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
id: row.id,
|
|
378
|
+
sessionId: row.session_id,
|
|
379
|
+
parentId: row.parent_id,
|
|
380
|
+
sequence: row.sequence,
|
|
381
|
+
state: row.state,
|
|
382
|
+
timestamp: row.timestamp,
|
|
383
|
+
completedAt: row.completed_at,
|
|
384
|
+
entry,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
function parseCommandAdmissionRow(raw) {
|
|
388
|
+
const decodedRow = decodeCommandAdmissionRow(raw);
|
|
389
|
+
if (decodedRow._tag === "Failure")
|
|
390
|
+
throw unavailable("Stored command admission row is invalid");
|
|
391
|
+
const row = decodedRow.success;
|
|
392
|
+
const decodedEntry = decodeStoredSessionEntry(row.data_json);
|
|
393
|
+
if (decodedEntry._tag === "Failure" ||
|
|
394
|
+
decodedEntry.success.kind !== "command")
|
|
395
|
+
throw unavailable("Stored command admission data is invalid");
|
|
396
|
+
const entry = decodedEntry.success;
|
|
397
|
+
if (row.request_id !== entry.requestId ||
|
|
398
|
+
row.work_id !== entry.workId ||
|
|
399
|
+
row.id !== entry.receiptId ||
|
|
400
|
+
row.id !== entry.workId ||
|
|
401
|
+
row.timestamp !== commandTimestamp(entry))
|
|
402
|
+
throw unavailable("Stored command admission columns disagree with its data");
|
|
403
|
+
const origin = decodeAdmissionOrigin(row.origin_kind === "user"
|
|
404
|
+
? { kind: "user" }
|
|
405
|
+
: {
|
|
406
|
+
kind: "extension",
|
|
407
|
+
extensionId: row.origin_id,
|
|
408
|
+
correlation: row.origin_correlation,
|
|
409
|
+
});
|
|
410
|
+
if (origin._tag === "Failure" ||
|
|
411
|
+
(row.origin_kind === "user" &&
|
|
412
|
+
(row.origin_id !== null || row.origin_correlation !== null)))
|
|
413
|
+
throw unavailable("Stored command admission origin is invalid");
|
|
414
|
+
return Object.freeze({
|
|
415
|
+
id: row.id,
|
|
416
|
+
sessionId: row.session_id,
|
|
417
|
+
sequence: row.sequence,
|
|
418
|
+
timestamp: row.timestamp,
|
|
419
|
+
entry,
|
|
420
|
+
origin: origin.success,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/** Bounded existence check; admission conditions never need transcript history. */
|
|
424
|
+
export function hasStoredSession(owner, sessionId) {
|
|
425
|
+
return readSessionRow(owner, sessionId) !== undefined;
|
|
426
|
+
}
|
|
427
|
+
function readSessionRow(owner, sessionId) {
|
|
428
|
+
const raw = owner.sql
|
|
429
|
+
.exec("SELECT id, leaf_id FROM sessions WHERE id = ?", sessionId)
|
|
430
|
+
.toArray()[0];
|
|
431
|
+
return raw === undefined ? undefined : parseSessionRow(raw);
|
|
432
|
+
}
|
|
433
|
+
function readEntryRow(owner, sessionId, id) {
|
|
434
|
+
const raw = owner.sql
|
|
435
|
+
.exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
|
|
436
|
+
request_id, work_id, data_json, timestamp, completed_at
|
|
437
|
+
FROM session_entries
|
|
438
|
+
WHERE session_id = ? AND id = ?`, sessionId, id)
|
|
439
|
+
.toArray()[0];
|
|
440
|
+
return raw === undefined ? undefined : parseEntryRow(raw);
|
|
441
|
+
}
|
|
442
|
+
function validateCompleteSessionLeaf(owner, session) {
|
|
443
|
+
if (session.leaf_id === null) {
|
|
444
|
+
const raw = owner.sql
|
|
445
|
+
.exec(`SELECT 1 AS present
|
|
446
|
+
FROM session_entries
|
|
447
|
+
WHERE session_id = ?
|
|
448
|
+
LIMIT 1`, session.id)
|
|
449
|
+
.toArray()[0];
|
|
450
|
+
if (raw === undefined)
|
|
451
|
+
return undefined;
|
|
452
|
+
const decoded = decodeEntryPresenceRow(raw);
|
|
453
|
+
if (decoded._tag === "Failure")
|
|
454
|
+
throw unavailable("Session entry presence is invalid");
|
|
455
|
+
throw unavailable("Session leaf is invalid");
|
|
456
|
+
}
|
|
457
|
+
const leaf = readEntryRow(owner, session.id, session.leaf_id);
|
|
458
|
+
if (leaf?.state !== "complete")
|
|
459
|
+
throw unavailable("Session leaf is invalid");
|
|
460
|
+
return leaf;
|
|
461
|
+
}
|
|
462
|
+
/** Read one retained Session entry by its globally unique row identity. */
|
|
463
|
+
export function readSessionEntryById(owner, id) {
|
|
464
|
+
const raw = owner.sql
|
|
465
|
+
.exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
|
|
466
|
+
request_id, work_id, data_json, timestamp, completed_at
|
|
467
|
+
FROM session_entries
|
|
468
|
+
WHERE id = ?`, id)
|
|
469
|
+
.toArray()[0];
|
|
470
|
+
return raw === undefined ? undefined : parseEntryRow(raw);
|
|
471
|
+
}
|
|
472
|
+
function readCommandAdmission(owner, sessionId, lookup) {
|
|
473
|
+
const [column, value] = "id" in lookup
|
|
474
|
+
? ["id", lookup.id]
|
|
475
|
+
: "requestId" in lookup
|
|
476
|
+
? ["request_id", lookup.requestId]
|
|
477
|
+
: ["work_id", lookup.workId];
|
|
478
|
+
const admitted = owner.sql
|
|
479
|
+
.exec(`SELECT ${admissionColumns("")}
|
|
480
|
+
FROM session_command_admissions
|
|
481
|
+
WHERE session_id = ? AND ${column} = ?`, sessionId, value)
|
|
482
|
+
.toArray()[0];
|
|
483
|
+
return admitted === undefined
|
|
484
|
+
? undefined
|
|
485
|
+
: parseCommandAdmissionRow(admitted);
|
|
486
|
+
}
|
|
487
|
+
const pendingAdmissions = `FROM session_command_admissions AS a
|
|
488
|
+
WHERE a.session_id = ?
|
|
489
|
+
AND NOT EXISTS (
|
|
490
|
+
SELECT 1 FROM session_entries AS e
|
|
491
|
+
WHERE e.session_id = a.session_id AND e.work_id = a.work_id
|
|
492
|
+
AND e.type IN ('command', 'cancellation', 'run_terminal', 'work_terminal')
|
|
493
|
+
)`;
|
|
494
|
+
const decodePendingCount = Schema.decodeUnknownSync(Schema.Struct({
|
|
495
|
+
total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
496
|
+
}));
|
|
497
|
+
/** Read waiting admission facts without consulting or recreating scheduler order. */
|
|
498
|
+
export function readPendingSessionCommands(owner, sessionId, limit, only = "all") {
|
|
499
|
+
// A waiting compaction request is accepted work but never a queued prompt.
|
|
500
|
+
const filter = only === "prompts" ? "AND json_extract(a.data_json, '$.work') IS NULL" : "";
|
|
501
|
+
const total = decodePendingCount(owner.sql
|
|
502
|
+
.exec(`SELECT COUNT(*) AS total ${pendingAdmissions} ${filter}`, sessionId)
|
|
503
|
+
.one()).total;
|
|
504
|
+
const commands = owner.sql
|
|
505
|
+
.exec(`SELECT ${admissionColumns("a.")} ${pendingAdmissions} ${filter}
|
|
506
|
+
ORDER BY a.sequence ASC LIMIT ?`, sessionId, limit)
|
|
507
|
+
.toArray()
|
|
508
|
+
.map(parseCommandAdmissionRow);
|
|
509
|
+
return { commands, total };
|
|
510
|
+
}
|
|
511
|
+
/** Read accepted steer/follow-up controls that remain waiting for delivery. */
|
|
512
|
+
function readPendingSessionControls(owner, sessionId, delivery, targetWorkId, limit = 200) {
|
|
513
|
+
return owner.sql
|
|
514
|
+
.exec(`SELECT ${admissionColumns("a.")}
|
|
515
|
+
FROM session_command_admissions AS a
|
|
516
|
+
WHERE a.session_id = ?
|
|
517
|
+
AND json_extract(a.data_json, '$.delivery') = ?
|
|
518
|
+
AND (? IS NULL OR json_extract(a.data_json, '$.targetWorkId') = ?)
|
|
519
|
+
AND NOT EXISTS (
|
|
520
|
+
SELECT 1 FROM session_entries AS e
|
|
521
|
+
WHERE e.session_id = a.session_id AND e.work_id = a.work_id
|
|
522
|
+
AND e.type IN ('command', 'run_terminal', 'work_terminal')
|
|
523
|
+
)
|
|
524
|
+
ORDER BY a.sequence ASC LIMIT ?`, sessionId, delivery, targetWorkId ?? null, targetWorkId ?? null, limit)
|
|
525
|
+
.toArray()
|
|
526
|
+
.map(parseCommandAdmissionRow);
|
|
527
|
+
}
|
|
528
|
+
/** Observe one pending steer without materializing or consuming its admission. */
|
|
529
|
+
export function hasPendingSessionSteer(owner, sessionId, workId) {
|
|
530
|
+
return (readPendingSessionControls(owner, sessionId, "steer", workId, 1).length > 0);
|
|
531
|
+
}
|
|
532
|
+
/** Materialize controls at one Mule safe point exactly once. */
|
|
533
|
+
export function takePendingSessionControls(owner, sessionId, delivery, targetWorkId) {
|
|
534
|
+
return owner.transactionSync(() => readPendingSessionControls(owner, sessionId, delivery, targetWorkId).map((admission) => {
|
|
535
|
+
const existing = readEntryRow(owner, sessionId, admission.id);
|
|
536
|
+
const control = promptCommand(existing?.entry ??
|
|
537
|
+
appendSessionEntry(owner, {
|
|
538
|
+
sessionId,
|
|
539
|
+
id: admission.id,
|
|
540
|
+
entry: admission.entry,
|
|
541
|
+
}).entry);
|
|
542
|
+
if (control === undefined)
|
|
543
|
+
throw unavailable("Session control admission is invalid");
|
|
544
|
+
return {
|
|
545
|
+
workId: control.workId,
|
|
546
|
+
requestId: control.requestId,
|
|
547
|
+
message: commandContextMessage(control),
|
|
548
|
+
};
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
/** Settle controls delivered by one parent run, exactly once per request. */
|
|
552
|
+
export function settleSessionControls(owner, input) {
|
|
553
|
+
owner.transactionSync(() => {
|
|
554
|
+
const rows = owner.sql
|
|
555
|
+
.exec(`SELECT ${admissionColumns("a.")}
|
|
556
|
+
FROM session_command_admissions AS a
|
|
557
|
+
WHERE a.session_id = ?
|
|
558
|
+
AND json_extract(a.data_json, '$.delivery') = 'steer'
|
|
559
|
+
AND json_extract(a.data_json, '$.targetWorkId') = ?
|
|
560
|
+
ORDER BY a.sequence ASC`, input.sessionId, input.parentWorkId)
|
|
561
|
+
.toArray()
|
|
562
|
+
.map(parseCommandAdmissionRow);
|
|
563
|
+
for (const row of rows) {
|
|
564
|
+
const alreadySettled = owner.sql
|
|
565
|
+
.exec("SELECT 1 AS present FROM session_entries WHERE session_id = ? AND work_id = ? AND type IN ('run_terminal', 'work_terminal') LIMIT 1", input.sessionId, row.entry.workId)
|
|
566
|
+
.toArray()[0];
|
|
567
|
+
if (alreadySettled !== undefined)
|
|
568
|
+
continue;
|
|
569
|
+
const materialized = readEntryRow(owner, input.sessionId, row.id);
|
|
570
|
+
if (materialized === undefined) {
|
|
571
|
+
input.enqueue({
|
|
572
|
+
jobId: row.entry.workId,
|
|
573
|
+
sessionId: input.sessionId,
|
|
574
|
+
commandFactId: row.id,
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
appendSessionEntry(owner, {
|
|
579
|
+
sessionId: input.sessionId,
|
|
580
|
+
id: sessionIds.fact.generate(),
|
|
581
|
+
entry: {
|
|
582
|
+
kind: "work_terminal",
|
|
583
|
+
workId: row.entry.workId,
|
|
584
|
+
outcome: input.outcome,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
/** Read one stable private command admission by accepted work identity. */
|
|
591
|
+
export function readSessionCommandAdmission(owner, sessionId, id) {
|
|
592
|
+
return readCommandAdmission(owner, sessionId, { id });
|
|
593
|
+
}
|
|
594
|
+
/** Read one command admission by its accepted work identity. */
|
|
595
|
+
function readSessionCommandAdmissionByWorkId(owner, sessionId, workId) {
|
|
596
|
+
return readCommandAdmission(owner, sessionId, { workId });
|
|
597
|
+
}
|
|
598
|
+
/** Read one stable private command admission by accepted request identity. */
|
|
599
|
+
export function readSessionCommandAdmissionByRequest(owner, sessionId, requestId) {
|
|
600
|
+
return readCommandAdmission(owner, sessionId, { requestId });
|
|
601
|
+
}
|
|
602
|
+
/** Read entries correlated to one accepted work identity in Session order. */
|
|
603
|
+
export function readSessionEntriesForWork(owner, sessionId, workId) {
|
|
604
|
+
return Object.freeze(owner.sql
|
|
605
|
+
.exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
|
|
606
|
+
request_id, work_id, data_json, timestamp, completed_at
|
|
607
|
+
FROM session_entries
|
|
608
|
+
WHERE session_id = ? AND work_id = ?
|
|
609
|
+
ORDER BY sequence ASC`, sessionId, workId)
|
|
610
|
+
.toArray()
|
|
611
|
+
.map(parseEntryRow));
|
|
612
|
+
}
|
|
613
|
+
/** Read the latest retained live-retry event for one accepted work item. */
|
|
614
|
+
export function readLatestLiveRetryForWork(owner, sessionId, workId) {
|
|
615
|
+
const raw = owner.sql
|
|
616
|
+
.exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
|
|
617
|
+
request_id, work_id, data_json, timestamp, completed_at
|
|
618
|
+
FROM session_entries
|
|
619
|
+
WHERE session_id = ? AND work_id = ? AND type = 'live_retry'
|
|
620
|
+
ORDER BY sequence DESC
|
|
621
|
+
LIMIT 1`, sessionId, workId)
|
|
622
|
+
.toArray()[0];
|
|
623
|
+
if (raw === undefined)
|
|
624
|
+
return undefined;
|
|
625
|
+
const row = parseEntryRow(raw);
|
|
626
|
+
if (row.entry.kind !== "live_retry")
|
|
627
|
+
throw unavailable("Stored live retry entry is invalid");
|
|
628
|
+
return { event: row.entry.event, timestamp: row.timestamp };
|
|
629
|
+
}
|
|
630
|
+
/** An indexed existence read: recovery errors never acknowledge successful delivery. */
|
|
631
|
+
export function hasSuccessfulToolResult(owner, sessionId, workId, toolCallId) {
|
|
632
|
+
return (owner.sql
|
|
633
|
+
.exec(`SELECT 1 FROM session_entries INDEXED BY idx_session_entries_successful_tool
|
|
634
|
+
WHERE session_id = ? AND work_id = ? AND type = 'tool_outcome'
|
|
635
|
+
AND json_extract(data_json, '$.result.isError') = 0
|
|
636
|
+
AND json_extract(data_json, '$.call.id') = ? LIMIT 1`, sessionId, workId, toolCallId)
|
|
637
|
+
.toArray().length > 0);
|
|
638
|
+
}
|
|
639
|
+
/** Most recent tool intent without a matching outcome for the running work. */
|
|
640
|
+
export function readActiveToolForWork(owner, sessionId, workId) {
|
|
641
|
+
const raw = owner.sql
|
|
642
|
+
.exec(`SELECT intent.id, intent.session_id, intent.parent_id,
|
|
643
|
+
intent.sequence, intent.type, intent.role, intent.state,
|
|
644
|
+
intent.request_id, intent.work_id, intent.data_json,
|
|
645
|
+
intent.timestamp, intent.completed_at
|
|
646
|
+
FROM session_entries AS intent
|
|
647
|
+
WHERE intent.session_id = ? AND intent.work_id = ?
|
|
648
|
+
AND intent.type = 'tool_intent'
|
|
649
|
+
AND NOT EXISTS (
|
|
650
|
+
SELECT 1 FROM session_entries AS outcome
|
|
651
|
+
WHERE outcome.session_id = intent.session_id
|
|
652
|
+
AND outcome.work_id = intent.work_id
|
|
653
|
+
AND outcome.type = 'tool_outcome'
|
|
654
|
+
AND json_extract(outcome.data_json, '$.toolCall.value') =
|
|
655
|
+
json_extract(intent.data_json, '$.toolCall.value')
|
|
656
|
+
)
|
|
657
|
+
ORDER BY intent.sequence DESC
|
|
658
|
+
LIMIT 1`, sessionId, workId)
|
|
659
|
+
.toArray()[0];
|
|
660
|
+
if (raw === undefined)
|
|
661
|
+
return undefined;
|
|
662
|
+
const row = parseEntryRow(raw);
|
|
663
|
+
if (row.entry.kind !== "tool_intent")
|
|
664
|
+
throw unavailable("Stored tool intent is invalid");
|
|
665
|
+
return row.entry.call.name;
|
|
666
|
+
}
|
|
667
|
+
function nextSequence(owner, sessionId) {
|
|
668
|
+
const raw = owner.sql
|
|
669
|
+
.exec(`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence
|
|
670
|
+
FROM session_entries
|
|
671
|
+
WHERE session_id = ?`, sessionId)
|
|
672
|
+
.one();
|
|
673
|
+
const decoded = decodeNextSequenceRow(raw);
|
|
674
|
+
if (decoded._tag === "Failure")
|
|
675
|
+
throw unavailable("Next Session sequence is invalid");
|
|
676
|
+
return decoded.success.next_sequence;
|
|
677
|
+
}
|
|
678
|
+
function nextAdmissionSequence(owner, sessionId) {
|
|
679
|
+
const raw = owner.sql
|
|
680
|
+
.exec(`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence
|
|
681
|
+
FROM (
|
|
682
|
+
SELECT sequence
|
|
683
|
+
FROM session_command_admissions
|
|
684
|
+
WHERE session_id = ?
|
|
685
|
+
UNION ALL
|
|
686
|
+
SELECT sequence
|
|
687
|
+
FROM session_entries
|
|
688
|
+
WHERE session_id = ? AND type = 'command'
|
|
689
|
+
)`, sessionId, sessionId)
|
|
690
|
+
.one();
|
|
691
|
+
const decoded = decodeNextSequenceRow(raw);
|
|
692
|
+
if (decoded._tag === "Failure")
|
|
693
|
+
throw unavailable("Next command admission sequence is invalid");
|
|
694
|
+
return decoded.success.next_sequence;
|
|
695
|
+
}
|
|
696
|
+
function ensureSession(owner, sessionId) {
|
|
697
|
+
const existing = readSessionRow(owner, sessionId);
|
|
698
|
+
if (existing !== undefined)
|
|
699
|
+
return existing;
|
|
700
|
+
const now = Date.now();
|
|
701
|
+
owner.sql.exec(`INSERT INTO sessions
|
|
702
|
+
(id, cwd, parent_session, leaf_id, created_at, updated_at, metadata_json)
|
|
703
|
+
VALUES (?, '/', NULL, NULL, ?, ?, NULL)`, sessionId, now, now);
|
|
704
|
+
touchSessionInbox(owner, { sessionId, activityAt: now, title: "" });
|
|
705
|
+
const created = readSessionRow(owner, sessionId);
|
|
706
|
+
if (created === undefined)
|
|
707
|
+
throw unavailable("Created Session row is unavailable");
|
|
708
|
+
return created;
|
|
709
|
+
}
|
|
710
|
+
/** Reserve or replay a durable Session identity before its first prompt. */
|
|
711
|
+
export function createStoredSession(owner, requestId,
|
|
712
|
+
/** Refuses a new Session under storage pressure; replays skip it. */
|
|
713
|
+
admitNew) {
|
|
714
|
+
return owner.transactionSync(() => {
|
|
715
|
+
const existing = owner.sql
|
|
716
|
+
.exec("SELECT session_id FROM session_creation_receipts WHERE request_id = ?", requestId)
|
|
717
|
+
.toArray();
|
|
718
|
+
const first = existing[0];
|
|
719
|
+
if (first !== undefined) {
|
|
720
|
+
const decoded = decodeSessionCreationReceiptRow(first);
|
|
721
|
+
if (decoded._tag === "Failure")
|
|
722
|
+
throw unavailable("Session creation receipt is invalid");
|
|
723
|
+
return decoded.success.session_id;
|
|
724
|
+
}
|
|
725
|
+
admitNew();
|
|
726
|
+
const sessionId = sessionIds.session.generate();
|
|
727
|
+
ensureSession(owner, sessionId);
|
|
728
|
+
owner.sql.exec("INSERT INTO session_creation_receipts (request_id, session_id) VALUES (?, ?)", requestId, sessionId);
|
|
729
|
+
return sessionId;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Permanently remove one idle Session: its entries, command admissions,
|
|
734
|
+
* creation receipt and inbox row, so a retried creation request reserves a
|
|
735
|
+
* fresh Session.
|
|
736
|
+
* Returns false when the Session does not exist.
|
|
737
|
+
*/
|
|
738
|
+
export function deleteStoredSession(owner, input) {
|
|
739
|
+
return owner.transactionSync(() => {
|
|
740
|
+
if (readSessionRow(owner, input.sessionId) === undefined)
|
|
741
|
+
return false;
|
|
742
|
+
input.assertIdleSession(input.sessionId);
|
|
743
|
+
input.recordInboxDeletion(input.sessionId);
|
|
744
|
+
for (const statement of [
|
|
745
|
+
"DELETE FROM session_entries WHERE session_id = ?",
|
|
746
|
+
"DELETE FROM session_command_admissions WHERE session_id = ?",
|
|
747
|
+
// The inbox row references the Session; it goes in the same transaction.
|
|
748
|
+
"DELETE FROM session_attention WHERE session_id = ?",
|
|
749
|
+
"DELETE FROM session_tool_grants WHERE session_id = ?",
|
|
750
|
+
"DELETE FROM session_inbox WHERE session_id = ?",
|
|
751
|
+
"DELETE FROM session_creation_receipts WHERE session_id = ?",
|
|
752
|
+
"DELETE FROM sessions WHERE id = ?",
|
|
753
|
+
])
|
|
754
|
+
owner.sql.exec(statement, input.sessionId);
|
|
755
|
+
return true;
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** The exact Woodstock SQL shape this build reads and writes. */
|
|
759
|
+
const currentWoodstockSchemaVersion = 1;
|
|
760
|
+
const currentWoodstockProtocol = "shiitake.extension/v0";
|
|
761
|
+
function tableExists(owner, name) {
|
|
762
|
+
const rows = owner.sql
|
|
763
|
+
.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", name)
|
|
764
|
+
.toArray();
|
|
765
|
+
const raw = rows[0];
|
|
766
|
+
if (raw === undefined)
|
|
767
|
+
return false;
|
|
768
|
+
const decoded = decodeTableNameRow(raw);
|
|
769
|
+
if (decoded._tag === "Failure")
|
|
770
|
+
throw unavailable("Store catalog row is invalid");
|
|
771
|
+
return true;
|
|
772
|
+
}
|
|
773
|
+
/** Read only the schema marker. A host that owns a file decides how to preserve it. */
|
|
774
|
+
function readWoodstockStoreProtocol(owner) {
|
|
775
|
+
if (!tableExists(owner, "woodstock_schema"))
|
|
776
|
+
return tableExists(owner, "sessions") ? "unmarked" : "fresh";
|
|
777
|
+
const columns = owner.sql
|
|
778
|
+
.exec("PRAGMA table_info(woodstock_schema)")
|
|
779
|
+
.toArray();
|
|
780
|
+
const hasProtocol = columns.some((row) => row.name === "protocol");
|
|
781
|
+
if (!hasProtocol)
|
|
782
|
+
return "unmarked";
|
|
783
|
+
const rows = owner.sql
|
|
784
|
+
.exec("SELECT version, protocol FROM woodstock_schema")
|
|
785
|
+
.toArray();
|
|
786
|
+
if (rows.length !== 1)
|
|
787
|
+
return "unmarked";
|
|
788
|
+
const decoded = decodeWoodstockSchemaVersionRow(rows[0]);
|
|
789
|
+
return decoded._tag === "Success" &&
|
|
790
|
+
decoded.success.version === currentWoodstockSchemaVersion
|
|
791
|
+
? "v0"
|
|
792
|
+
: "unmarked";
|
|
793
|
+
}
|
|
794
|
+
/** Stamp fresh Woodstock stores; incompatible files require host-side custody. */
|
|
795
|
+
function ensureWoodstockSchemaVersion(owner) {
|
|
796
|
+
const protocol = readWoodstockStoreProtocol(owner);
|
|
797
|
+
if (protocol === "v0")
|
|
798
|
+
return;
|
|
799
|
+
if (protocol === "unmarked")
|
|
800
|
+
throw incompatible("Woodstock store has no v0 protocol marker; a hosted store never resets itself — reset the owning Durable Object storage, or remove the old local store file before opening a fresh one");
|
|
801
|
+
owner.sql.exec("CREATE TABLE woodstock_schema (version INTEGER NOT NULL, protocol TEXT NOT NULL)");
|
|
802
|
+
owner.sql.exec("INSERT INTO woodstock_schema (version, protocol) VALUES (?, ?)", currentWoodstockSchemaVersion, currentWoodstockProtocol);
|
|
803
|
+
}
|
|
804
|
+
function createSessionEntryTable(owner) {
|
|
805
|
+
owner.sql.exec(`
|
|
806
|
+
CREATE TABLE IF NOT EXISTS session_entries (
|
|
807
|
+
id TEXT PRIMARY KEY,
|
|
808
|
+
session_id TEXT NOT NULL,
|
|
809
|
+
parent_id TEXT,
|
|
810
|
+
sequence INTEGER NOT NULL,
|
|
811
|
+
type TEXT NOT NULL,
|
|
812
|
+
role TEXT,
|
|
813
|
+
state TEXT NOT NULL CHECK (state IN ('complete', 'streaming')),
|
|
814
|
+
request_id TEXT,
|
|
815
|
+
work_id TEXT,
|
|
816
|
+
data_json TEXT NOT NULL,
|
|
817
|
+
timestamp INTEGER NOT NULL,
|
|
818
|
+
completed_at INTEGER,
|
|
819
|
+
UNIQUE (session_id, sequence)
|
|
820
|
+
)
|
|
821
|
+
`);
|
|
822
|
+
}
|
|
823
|
+
/** Create the retained Session schema and private creation/admission receipts. */
|
|
824
|
+
export function prepareSessionStore(owner) {
|
|
825
|
+
owner.transactionSync(() => {
|
|
826
|
+
ensureWoodstockSchemaVersion(owner);
|
|
827
|
+
owner.sql.exec(`
|
|
828
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
829
|
+
id TEXT PRIMARY KEY,
|
|
830
|
+
cwd TEXT NOT NULL,
|
|
831
|
+
parent_session TEXT,
|
|
832
|
+
leaf_id TEXT,
|
|
833
|
+
created_at INTEGER NOT NULL,
|
|
834
|
+
updated_at INTEGER NOT NULL,
|
|
835
|
+
metadata_json TEXT
|
|
836
|
+
)
|
|
837
|
+
`);
|
|
838
|
+
createSessionEntryTable(owner);
|
|
839
|
+
prepareSessionDocuments(owner);
|
|
840
|
+
prepareSessionAttention(owner);
|
|
841
|
+
prepareSessionGrants(owner);
|
|
842
|
+
prepareSessionInbox(owner);
|
|
843
|
+
owner.sql.exec(`
|
|
844
|
+
CREATE TABLE IF NOT EXISTS session_creation_receipts (
|
|
845
|
+
request_id TEXT PRIMARY KEY,
|
|
846
|
+
session_id TEXT NOT NULL UNIQUE REFERENCES sessions(id)
|
|
847
|
+
)
|
|
848
|
+
`);
|
|
849
|
+
owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_parent ON session_entries(parent_id)");
|
|
850
|
+
owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_session ON session_entries(session_id, sequence)");
|
|
851
|
+
owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_streaming ON session_entries(session_id, state)");
|
|
852
|
+
owner.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_session_entries_request
|
|
853
|
+
ON session_entries(session_id, request_id)
|
|
854
|
+
WHERE request_id IS NOT NULL`);
|
|
855
|
+
owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_work ON session_entries(work_id, sequence)");
|
|
856
|
+
owner.sql
|
|
857
|
+
.exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_successful_tool
|
|
858
|
+
ON session_entries(session_id, work_id, json_extract(data_json, '$.call.id'))
|
|
859
|
+
WHERE type = 'tool_outcome' AND json_extract(data_json, '$.result.isError') = 0`);
|
|
860
|
+
owner.sql.exec(`
|
|
861
|
+
CREATE TABLE IF NOT EXISTS session_command_admissions (
|
|
862
|
+
id TEXT PRIMARY KEY,
|
|
863
|
+
session_id TEXT NOT NULL,
|
|
864
|
+
sequence INTEGER NOT NULL,
|
|
865
|
+
request_id TEXT NOT NULL,
|
|
866
|
+
work_id TEXT NOT NULL,
|
|
867
|
+
data_json TEXT NOT NULL,
|
|
868
|
+
timestamp INTEGER NOT NULL,
|
|
869
|
+
origin_kind TEXT NOT NULL CHECK (origin_kind IN ('user', 'extension')),
|
|
870
|
+
origin_id TEXT,
|
|
871
|
+
origin_correlation TEXT,
|
|
872
|
+
CHECK ((origin_kind = 'user') = (origin_id IS NULL)),
|
|
873
|
+
CHECK (origin_kind = 'extension' OR origin_correlation IS NULL),
|
|
874
|
+
-- This orders durable acceptance facts, not conversation entries.
|
|
875
|
+
UNIQUE (session_id, sequence),
|
|
876
|
+
UNIQUE (session_id, request_id),
|
|
877
|
+
UNIQUE (session_id, work_id)
|
|
878
|
+
)
|
|
879
|
+
`);
|
|
880
|
+
});
|
|
881
|
+
prepareExtensionStore(owner);
|
|
882
|
+
}
|
|
883
|
+
/** Read the nullable preference at exactly this selected lineage. */
|
|
884
|
+
export function readSessionModelPreference(owner, sessionId) {
|
|
885
|
+
return lineageModelPreference(owner, readSessionLineage(owner, { sessionId }));
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Woodstock order of the newest model choice on the selected lineage, or null.
|
|
889
|
+
* Clients compare it with terminal orders; Woodstock assigns it no meaning.
|
|
890
|
+
*/
|
|
891
|
+
export function readLastModelChoiceSequence(owner, sessionId) {
|
|
892
|
+
const leaf = readSessionRow(owner, sessionId)?.leaf_id;
|
|
893
|
+
if (leaf === undefined || leaf === null)
|
|
894
|
+
return null;
|
|
895
|
+
const raw = owner.sql
|
|
896
|
+
.exec(`WITH RECURSIVE branch (id, parent_id, sequence, type, state) AS (
|
|
897
|
+
SELECT id, parent_id, sequence, type, state
|
|
898
|
+
FROM session_entries
|
|
899
|
+
WHERE session_id = ? AND id = ?
|
|
900
|
+
UNION
|
|
901
|
+
SELECT parent.id, parent.parent_id, parent.sequence, parent.type,
|
|
902
|
+
parent.state
|
|
903
|
+
FROM session_entries AS parent
|
|
904
|
+
JOIN branch AS child ON parent.id = child.parent_id
|
|
905
|
+
WHERE parent.session_id = ?
|
|
906
|
+
)
|
|
907
|
+
SELECT MAX(sequence) AS sequence
|
|
908
|
+
FROM branch
|
|
909
|
+
WHERE type = 'model_preference' AND state != 'streaming'`, sessionId, leaf, sessionId)
|
|
910
|
+
.one();
|
|
911
|
+
const decoded = decodeModelChoiceRow(raw);
|
|
912
|
+
if (decoded._tag === "Failure")
|
|
913
|
+
throw unavailable("Stored model choice order is invalid");
|
|
914
|
+
return decoded.success.sequence;
|
|
915
|
+
}
|
|
916
|
+
/** Append a decoded preference request; no command or scheduler work is minted. */
|
|
917
|
+
export function setSessionModelPreference(owner, sessionId, preference) {
|
|
918
|
+
return owner.transactionSync(() => appendSessionEntry(owner, {
|
|
919
|
+
sessionId,
|
|
920
|
+
id: sessionIds.fact.generate(),
|
|
921
|
+
entry: { kind: "model_preference", workId: null, preference },
|
|
922
|
+
}));
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Set or clear the user's Session title. Display metadata only: no entry is
|
|
926
|
+
* appended, so history, the selected leaf and recency are unchanged. Setting
|
|
927
|
+
* the same value again is a no-op write.
|
|
928
|
+
*/
|
|
929
|
+
export function setSessionTitle(owner, sessionId, title) {
|
|
930
|
+
owner.transactionSync(() => {
|
|
931
|
+
ensureSession(owner, sessionId);
|
|
932
|
+
owner.sql.exec("UPDATE sessions SET metadata_json = ? WHERE id = ?", title === null ? null : encodeSessionMetadata({ title }), sessionId);
|
|
933
|
+
touchSessionInbox(owner, {
|
|
934
|
+
sessionId,
|
|
935
|
+
title: inboxTitle(owner, sessionId),
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* The retained append word.
|
|
941
|
+
*
|
|
942
|
+
* Complete entries advance the selected leaf. A streaming message placeholder
|
|
943
|
+
* is durable but deliberately leaves the selected branch unchanged.
|
|
944
|
+
*/
|
|
945
|
+
export function appendSessionEntry(owner, input) {
|
|
946
|
+
const session = ensureSession(owner, input.sessionId);
|
|
947
|
+
const leaf = validateCompleteSessionLeaf(owner, session);
|
|
948
|
+
const state = input.state ?? "complete";
|
|
949
|
+
if ((state === "streaming") !== (input.entry.kind === "message_start"))
|
|
950
|
+
throw unavailable("A message start must be the streaming entry");
|
|
951
|
+
if (state === "streaming" &&
|
|
952
|
+
input.entry.kind === "message_start" &&
|
|
953
|
+
input.id !== input.entry.message.value)
|
|
954
|
+
throw unavailable("Message placeholder identity does not match its row");
|
|
955
|
+
const now = Date.now();
|
|
956
|
+
const entrySequence = nextSequence(owner, input.sessionId);
|
|
957
|
+
owner.sql.exec(`INSERT INTO session_entries
|
|
958
|
+
(id, session_id, parent_id, sequence, type, role, state,
|
|
959
|
+
request_id, work_id, data_json, timestamp, completed_at)
|
|
960
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.id, input.sessionId, leaf?.id ?? null, entrySequence, input.entry.kind, roleFor(input.entry), state, requestIdFor(input.entry), input.entry.workId, encodeStoredSessionEntry(input.entry), now, state === "complete" ? now : null);
|
|
961
|
+
if (state === "complete") {
|
|
962
|
+
owner.sql.exec("UPDATE sessions SET leaf_id = ?, updated_at = ? WHERE id = ?", input.id, now, input.sessionId);
|
|
963
|
+
// Only an entering prompt can change the first-prompt title.
|
|
964
|
+
touchSessionInbox(owner, input.entry.kind === "command"
|
|
965
|
+
? {
|
|
966
|
+
sessionId: input.sessionId,
|
|
967
|
+
activityAt: now,
|
|
968
|
+
title: inboxTitle(owner, input.sessionId),
|
|
969
|
+
}
|
|
970
|
+
: { sessionId: input.sessionId, activityAt: now });
|
|
971
|
+
}
|
|
972
|
+
const appended = readEntryRow(owner, input.sessionId, input.id);
|
|
973
|
+
if (appended === undefined)
|
|
974
|
+
throw unavailable("Appended Session entry is unavailable");
|
|
975
|
+
return appended;
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* The retained leaf-finalize word.
|
|
979
|
+
*
|
|
980
|
+
* The private placeholder keeps its message identity. When the complete leaf
|
|
981
|
+
* advanced during streaming, finish after that leaf with a fresh sequence.
|
|
982
|
+
* Otherwise retain its original position. Completed history is never moved.
|
|
983
|
+
*/
|
|
984
|
+
export function completeSessionLeaf(owner, input) {
|
|
985
|
+
const session = ensureSession(owner, input.sessionId);
|
|
986
|
+
const leaf = validateCompleteSessionLeaf(owner, session);
|
|
987
|
+
const existing = readEntryRow(owner, input.sessionId, input.id);
|
|
988
|
+
const now = Date.now();
|
|
989
|
+
// The placeholder is private and has never advanced the selected lineage.
|
|
990
|
+
// Preserve complete preferences/cancellations committed while it streamed.
|
|
991
|
+
const leafAdvanced = existing?.state === "streaming" && existing.parentId !== (leaf?.id ?? null);
|
|
992
|
+
const parentId = leafAdvanced
|
|
993
|
+
? (leaf?.id ?? null)
|
|
994
|
+
: (existing?.parentId ?? leaf?.id ?? null);
|
|
995
|
+
const entrySequence = leafAdvanced
|
|
996
|
+
? nextSequence(owner, input.sessionId)
|
|
997
|
+
: (existing?.sequence ?? nextSequence(owner, input.sessionId));
|
|
998
|
+
const createdAt = existing?.timestamp ?? now;
|
|
999
|
+
if (input.id !== input.entry.message.value)
|
|
1000
|
+
throw unavailable("Completed message identity does not match its row");
|
|
1001
|
+
if (existing !== undefined) {
|
|
1002
|
+
const previous = existing.entry;
|
|
1003
|
+
if (previous.kind !== "message_start" && previous.kind !== "message")
|
|
1004
|
+
throw unavailable("Completed Session leaf is not a message entry");
|
|
1005
|
+
if (previous.workId !== input.entry.workId ||
|
|
1006
|
+
!sameCausalIdentity(previous.run, input.entry.run) ||
|
|
1007
|
+
!sameCausalIdentity(previous.turn, input.entry.turn) ||
|
|
1008
|
+
!sameCausalIdentity(previous.message, input.entry.message) ||
|
|
1009
|
+
(previous.kind === "message_start" &&
|
|
1010
|
+
previous.role !== input.entry.value.role))
|
|
1011
|
+
throw unavailable("Completed message does not match its placeholder");
|
|
1012
|
+
}
|
|
1013
|
+
owner.sql.exec(`INSERT OR REPLACE INTO session_entries
|
|
1014
|
+
(id, session_id, parent_id, sequence, type, role, state,
|
|
1015
|
+
request_id, work_id, data_json, timestamp, completed_at)
|
|
1016
|
+
VALUES (?, ?, ?, ?, 'message', ?, 'complete',
|
|
1017
|
+
NULL, ?, ?, ?, ?)`, input.id, input.sessionId, parentId, entrySequence, roleFor(input.entry), input.entry.workId, encodeStoredSessionEntry(input.entry), createdAt, now);
|
|
1018
|
+
owner.sql.exec("UPDATE sessions SET leaf_id = ?, updated_at = ? WHERE id = ?", input.id, now, input.sessionId);
|
|
1019
|
+
touchSessionInbox(owner, { sessionId: input.sessionId, activityAt: now });
|
|
1020
|
+
const completed = readEntryRow(owner, input.sessionId, input.id);
|
|
1021
|
+
if (completed === undefined)
|
|
1022
|
+
throw unavailable("Completed Session leaf is unavailable");
|
|
1023
|
+
return completed;
|
|
1024
|
+
}
|
|
1025
|
+
const entryColumns = `id, session_id, parent_id, sequence, type, role, state,
|
|
1026
|
+
request_id, work_id, data_json, timestamp, completed_at`;
|
|
1027
|
+
/** Rows one bounded lineage read decodes at once. */
|
|
1028
|
+
export const lineageRowLimit = 200;
|
|
1029
|
+
/**
|
|
1030
|
+
* Stored bytes one model context read may decode: the retained storage
|
|
1031
|
+
* plane's `LLM_CONTEXT_BYTES`, now a typed refusal rather than a silent
|
|
1032
|
+
* truncation, because a truncated context would change the model's input.
|
|
1033
|
+
*/
|
|
1034
|
+
export const contextByteLimit = 12 * 1_048_576;
|
|
1035
|
+
/**
|
|
1036
|
+
* Rows one fork copy window decodes. A fork copies the whole lineage, so a
|
|
1037
|
+
* wide window keeps the walks few; the byte bound still caps each window.
|
|
1038
|
+
*/
|
|
1039
|
+
const forkWindowRows = 4_000;
|
|
1040
|
+
/**
|
|
1041
|
+
* Stored entry bytes one bounded lineage read decodes at once. One row is
|
|
1042
|
+
* always read, so a single oversized entry still makes progress.
|
|
1043
|
+
*/
|
|
1044
|
+
const lineageByteLimit = 4 * 1_048_576;
|
|
1045
|
+
/**
|
|
1046
|
+
* Root-to-tip walks carry identities only (the 2026-08-15 connection-plane
|
|
1047
|
+
* law); entry data is read afterwards for exactly the rows a reader needs.
|
|
1048
|
+
* The selected walk drops streaming placeholders after walking; the fork walk
|
|
1049
|
+
* stops at them, as a fork copies only complete history.
|
|
1050
|
+
*/
|
|
1051
|
+
const lineageWalks = {
|
|
1052
|
+
selected: `WITH RECURSIVE branch (id, parent_id, sequence, state) AS (
|
|
1053
|
+
SELECT id, parent_id, sequence, state
|
|
1054
|
+
FROM session_entries
|
|
1055
|
+
WHERE session_id = ? AND id = ?
|
|
1056
|
+
UNION
|
|
1057
|
+
SELECT parent.id, parent.parent_id, parent.sequence, parent.state
|
|
1058
|
+
FROM session_entries AS parent
|
|
1059
|
+
JOIN branch AS child ON parent.id = child.parent_id
|
|
1060
|
+
WHERE parent.session_id = ?
|
|
1061
|
+
)`,
|
|
1062
|
+
fork: `WITH RECURSIVE branch (id, parent_id, sequence, state) AS (
|
|
1063
|
+
SELECT id, parent_id, sequence, state
|
|
1064
|
+
FROM session_entries
|
|
1065
|
+
WHERE session_id = ? AND id = ? AND state != 'streaming'
|
|
1066
|
+
UNION
|
|
1067
|
+
SELECT parent.id, parent.parent_id, parent.sequence, parent.state
|
|
1068
|
+
FROM session_entries AS parent
|
|
1069
|
+
JOIN branch AS child ON parent.id = child.parent_id
|
|
1070
|
+
WHERE parent.session_id = ? AND parent.state != 'streaming'
|
|
1071
|
+
)`,
|
|
1072
|
+
};
|
|
1073
|
+
const lineageFailures = {
|
|
1074
|
+
selected: {
|
|
1075
|
+
empty: "Selected Session leaf is unavailable",
|
|
1076
|
+
missingParent: "Selected Session path has a missing parent",
|
|
1077
|
+
invalid: "Selected Session path is invalid",
|
|
1078
|
+
},
|
|
1079
|
+
fork: {
|
|
1080
|
+
empty: "Fork entry is unavailable",
|
|
1081
|
+
missingParent: "Fork path has a missing parent",
|
|
1082
|
+
invalid: "Fork path is invalid",
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
const count = Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0)));
|
|
1086
|
+
const decodeLineageCheck = Schema.decodeUnknownResult(Schema.Struct({
|
|
1087
|
+
length: count,
|
|
1088
|
+
root_id: Schema.NullOr(boundedId),
|
|
1089
|
+
root_parent_id: Schema.NullOr(Schema.String),
|
|
1090
|
+
broken: count,
|
|
1091
|
+
newest_id: Schema.NullOr(boundedId),
|
|
1092
|
+
newest_sequence: Schema.NullOr(sequence),
|
|
1093
|
+
}), closed);
|
|
1094
|
+
const decodeLineageSize = Schema.decodeUnknownResult(Schema.Struct({ sequence, bytes: count }), closed);
|
|
1095
|
+
const decodeContextBytes = Schema.decodeUnknownResult(Schema.Struct({ bytes: count }), closed);
|
|
1096
|
+
const decodeLineageSequence = Schema.decodeUnknownResult(Schema.Struct({ sequence }), closed);
|
|
1097
|
+
const decodeFirstKept = Schema.decodeUnknownResult(Schema.Struct({ first_kept: boundedId }), closed);
|
|
1098
|
+
const decodeLineageWorkId = Schema.decodeUnknownResult(Schema.Struct({ work_id: boundedId }), closed);
|
|
1099
|
+
const decodeLineageDiagnostic = Schema.decodeUnknownResult(Schema.Struct({ work_id: boundedId, diagnostic: Schema.String }), closed);
|
|
1100
|
+
function lineageQuery(owner, lineage, query, ...bindings) {
|
|
1101
|
+
return owner.sql
|
|
1102
|
+
.exec(`${lineageWalks[lineage.walk]} ${query}`, lineage.sessionId, lineage.tipId, lineage.sessionId, ...bindings)
|
|
1103
|
+
.toArray();
|
|
1104
|
+
}
|
|
1105
|
+
/** Validate one root-to-tip chain in SQL, the same way decoded rows would be. */
|
|
1106
|
+
function locateLineage(owner, sessionId, tipId, walk) {
|
|
1107
|
+
const failures = lineageFailures[walk];
|
|
1108
|
+
const checked = decodeLineageCheck(owner.sql
|
|
1109
|
+
.exec(`${lineageWalks[walk]},
|
|
1110
|
+
kept AS MATERIALIZED (
|
|
1111
|
+
SELECT id, parent_id, sequence FROM branch
|
|
1112
|
+
WHERE state != 'streaming'
|
|
1113
|
+
),
|
|
1114
|
+
links AS (
|
|
1115
|
+
SELECT parent_id, LAG(id) OVER (ORDER BY sequence) AS previous
|
|
1116
|
+
FROM kept
|
|
1117
|
+
)
|
|
1118
|
+
SELECT
|
|
1119
|
+
(SELECT COUNT(*) FROM kept) AS length,
|
|
1120
|
+
(SELECT id FROM kept ORDER BY sequence ASC LIMIT 1) AS root_id,
|
|
1121
|
+
(SELECT parent_id FROM kept ORDER BY sequence ASC LIMIT 1)
|
|
1122
|
+
AS root_parent_id,
|
|
1123
|
+
(SELECT COUNT(*) FROM links
|
|
1124
|
+
WHERE previous IS NOT NULL AND parent_id IS NOT previous) AS broken,
|
|
1125
|
+
(SELECT id FROM kept ORDER BY sequence DESC LIMIT 1) AS newest_id,
|
|
1126
|
+
(SELECT sequence FROM kept ORDER BY sequence DESC LIMIT 1)
|
|
1127
|
+
AS newest_sequence`, sessionId, tipId, sessionId)
|
|
1128
|
+
.one());
|
|
1129
|
+
if (checked._tag === "Failure")
|
|
1130
|
+
throw unavailable("Stored Session lineage check is invalid");
|
|
1131
|
+
const lineage = checked.success;
|
|
1132
|
+
if (lineage.length === 0 ||
|
|
1133
|
+
lineage.root_id === null ||
|
|
1134
|
+
lineage.newest_id === null ||
|
|
1135
|
+
lineage.newest_sequence === null)
|
|
1136
|
+
throw unavailable(failures.empty);
|
|
1137
|
+
if (lineage.root_parent_id !== null)
|
|
1138
|
+
throw unavailable(failures.missingParent);
|
|
1139
|
+
if (lineage.broken !== 0)
|
|
1140
|
+
throw unavailable(failures.invalid);
|
|
1141
|
+
return Object.freeze({
|
|
1142
|
+
sessionId,
|
|
1143
|
+
walk,
|
|
1144
|
+
tipId,
|
|
1145
|
+
rootId: lineage.root_id,
|
|
1146
|
+
newestId: lineage.newest_id,
|
|
1147
|
+
newestSequence: lineage.newest_sequence,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
/** Locate the selected lineage; undefined when the Session has no leaf. */
|
|
1151
|
+
export function readSessionLineage(owner, input) {
|
|
1152
|
+
const tip = input.leafId ?? readSessionRow(owner, input.sessionId)?.leaf_id;
|
|
1153
|
+
if (tip === undefined || tip === null)
|
|
1154
|
+
return undefined;
|
|
1155
|
+
return locateLineage(owner, input.sessionId, tip, "selected");
|
|
1156
|
+
}
|
|
1157
|
+
function readLineageRange(owner, lineage, oldest, newest) {
|
|
1158
|
+
return Object.freeze(lineageQuery(owner, lineage, `SELECT ${entryColumns}
|
|
1159
|
+
FROM session_entries
|
|
1160
|
+
WHERE session_id = ? AND state != 'streaming'
|
|
1161
|
+
AND sequence BETWEEN ? AND ?
|
|
1162
|
+
AND id IN (SELECT id FROM branch)
|
|
1163
|
+
ORDER BY sequence ASC`, lineage.sessionId, oldest, newest).map(parseEntryRow));
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Read one contiguous window of a lineage, bounded by row count and by stored
|
|
1167
|
+
* bytes. Row sizes are measured in SQL before any entry is decoded.
|
|
1168
|
+
*/
|
|
1169
|
+
export function readLineageWindow(owner, lineage, input) {
|
|
1170
|
+
const sizes = lineageQuery(owner, lineage, `SELECT sequence, LENGTH(CAST(data_json AS BLOB)) AS bytes
|
|
1171
|
+
FROM session_entries
|
|
1172
|
+
WHERE session_id = ? AND state != 'streaming'
|
|
1173
|
+
AND sequence > ? AND sequence < ?
|
|
1174
|
+
AND id IN (SELECT id FROM branch)
|
|
1175
|
+
ORDER BY sequence ${input.from === "newest" ? "DESC" : "ASC"}
|
|
1176
|
+
LIMIT ?`, lineage.sessionId, input.after ?? 0, input.before ?? Number.MAX_SAFE_INTEGER, input.rows + 1).map((raw) => {
|
|
1177
|
+
const decoded = decodeLineageSize(raw);
|
|
1178
|
+
if (decoded._tag === "Failure")
|
|
1179
|
+
throw unavailable("Stored Session entry size is invalid");
|
|
1180
|
+
return decoded.success;
|
|
1181
|
+
});
|
|
1182
|
+
let taken = 0;
|
|
1183
|
+
let bytes = 0;
|
|
1184
|
+
for (const size of sizes) {
|
|
1185
|
+
if (taken === input.rows)
|
|
1186
|
+
break;
|
|
1187
|
+
if (taken > 0 && bytes + size.bytes > lineageByteLimit)
|
|
1188
|
+
break;
|
|
1189
|
+
bytes += size.bytes;
|
|
1190
|
+
taken += 1;
|
|
1191
|
+
}
|
|
1192
|
+
const first = sizes[0];
|
|
1193
|
+
const last = sizes[taken - 1];
|
|
1194
|
+
if (first === undefined || last === undefined)
|
|
1195
|
+
return Object.freeze({ rows: Object.freeze([]), more: false });
|
|
1196
|
+
return Object.freeze({
|
|
1197
|
+
rows: readLineageRange(owner, lineage, Math.min(first.sequence, last.sequence), Math.max(first.sequence, last.sequence)),
|
|
1198
|
+
more: sizes.length > taken,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
/** Whether a complete row with this sequence lies on the lineage. */
|
|
1202
|
+
export function lineageHasSequence(owner, lineage, rowSequence) {
|
|
1203
|
+
return (lineageQuery(owner, lineage, `SELECT 1 AS present
|
|
1204
|
+
FROM session_entries
|
|
1205
|
+
WHERE session_id = ? AND state != 'streaming' AND sequence = ?
|
|
1206
|
+
AND id IN (SELECT id FROM branch)`, lineage.sessionId, rowSequence).length > 0);
|
|
1207
|
+
}
|
|
1208
|
+
/** The sequence of one complete row on the lineage, if it is there. */
|
|
1209
|
+
export function lineageEntrySequence(owner, lineage, id) {
|
|
1210
|
+
const raw = lineageQuery(owner, lineage, `SELECT sequence
|
|
1211
|
+
FROM session_entries
|
|
1212
|
+
WHERE session_id = ? AND state != 'streaming' AND id = ?
|
|
1213
|
+
AND id IN (SELECT id FROM branch)`, lineage.sessionId, id)[0];
|
|
1214
|
+
if (raw === undefined)
|
|
1215
|
+
return undefined;
|
|
1216
|
+
const decoded = decodeLineageSequence(raw);
|
|
1217
|
+
if (decoded._tag === "Failure")
|
|
1218
|
+
throw unavailable("Stored Session entry sequence is invalid");
|
|
1219
|
+
return decoded.success.sequence;
|
|
1220
|
+
}
|
|
1221
|
+
/** The newest lineage row of one entry kind, decoded. */
|
|
1222
|
+
function readNewestLineageEntry(owner, lineage, kind) {
|
|
1223
|
+
const raw = lineageQuery(owner, lineage, `SELECT ${entryColumns}
|
|
1224
|
+
FROM session_entries
|
|
1225
|
+
WHERE session_id = ? AND state != 'streaming' AND type = ?
|
|
1226
|
+
AND id IN (SELECT id FROM branch)
|
|
1227
|
+
ORDER BY sequence DESC
|
|
1228
|
+
LIMIT 1`, lineage.sessionId, kind)[0];
|
|
1229
|
+
return raw === undefined ? undefined : parseEntryRow(raw);
|
|
1230
|
+
}
|
|
1231
|
+
function lineageModelPreference(owner, lineage) {
|
|
1232
|
+
const entry = lineage === undefined
|
|
1233
|
+
? undefined
|
|
1234
|
+
: readNewestLineageEntry(owner, lineage, "model_preference")?.entry;
|
|
1235
|
+
return entry?.kind === "model_preference" ? entry.preference : null;
|
|
1236
|
+
}
|
|
1237
|
+
/** Latest document value on this exact lineage, not a global last write. */
|
|
1238
|
+
function readLineageDocument(owner, lineage, extensionId, name) {
|
|
1239
|
+
if (lineage === undefined)
|
|
1240
|
+
return undefined;
|
|
1241
|
+
const raw = lineageQuery(owner, lineage, `SELECT ${entryColumns}
|
|
1242
|
+
FROM session_entries
|
|
1243
|
+
WHERE session_id = ? AND state != 'streaming' AND type = 'document'
|
|
1244
|
+
AND json_extract(data_json, '$.extensionId') = ?
|
|
1245
|
+
AND json_extract(data_json, '$.name') = ?
|
|
1246
|
+
AND id IN (SELECT id FROM branch)
|
|
1247
|
+
ORDER BY sequence DESC
|
|
1248
|
+
LIMIT 1`, lineage.sessionId, extensionId, name)[0];
|
|
1249
|
+
const entry = raw === undefined ? undefined : parseEntryRow(raw).entry;
|
|
1250
|
+
if (entry === undefined)
|
|
1251
|
+
return undefined;
|
|
1252
|
+
if (entry.kind !== "document" ||
|
|
1253
|
+
entry.extensionId !== extensionId ||
|
|
1254
|
+
entry.name !== name)
|
|
1255
|
+
throw unavailable("Stored Session document disagrees with its row");
|
|
1256
|
+
return entry.valueJson;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Look up, across the whole lineage, only the facts of the given works. A
|
|
1260
|
+
* diagnostic is the newest of an assistant error, a final retry error or a
|
|
1261
|
+
* work terminal's diagnostic; only that text is extracted, never the message.
|
|
1262
|
+
*/
|
|
1263
|
+
export function readLineageWorkFacts(owner, lineage, workIds) {
|
|
1264
|
+
if (workIds.size === 0)
|
|
1265
|
+
return Object.freeze({
|
|
1266
|
+
commands: new Set(),
|
|
1267
|
+
diagnostics: new Map(),
|
|
1268
|
+
cancellations: new Map(),
|
|
1269
|
+
});
|
|
1270
|
+
const works = JSON.stringify([...workIds]);
|
|
1271
|
+
const commands = new Set(lineageQuery(owner, lineage, `SELECT DISTINCT work_id
|
|
1272
|
+
FROM session_entries
|
|
1273
|
+
WHERE session_id = ? AND state != 'streaming' AND type = 'command'
|
|
1274
|
+
AND work_id IN (SELECT value FROM json_each(?))
|
|
1275
|
+
AND id IN (SELECT id FROM branch)`, lineage.sessionId, works).map((raw) => {
|
|
1276
|
+
const decoded = decodeLineageWorkId(raw);
|
|
1277
|
+
if (decoded._tag === "Failure")
|
|
1278
|
+
throw unavailable("Stored Session command work is invalid");
|
|
1279
|
+
return decoded.success.work_id;
|
|
1280
|
+
}));
|
|
1281
|
+
const diagnostics = new Map();
|
|
1282
|
+
for (const raw of lineageQuery(owner, lineage, `SELECT work_id, diagnostic FROM (
|
|
1283
|
+
SELECT work_id, sequence,
|
|
1284
|
+
CASE
|
|
1285
|
+
WHEN type = 'work_terminal'
|
|
1286
|
+
THEN json_extract(data_json, '$.diagnostic')
|
|
1287
|
+
WHEN type = 'message'
|
|
1288
|
+
THEN json_extract(data_json, '$.value.errorMessage')
|
|
1289
|
+
WHEN json_extract(data_json, '$.event.type') = 'auto_retry_end'
|
|
1290
|
+
THEN json_extract(data_json, '$.event.finalError')
|
|
1291
|
+
END AS diagnostic
|
|
1292
|
+
FROM session_entries
|
|
1293
|
+
WHERE session_id = ? AND state != 'streaming'
|
|
1294
|
+
AND work_id IN (SELECT value FROM json_each(?))
|
|
1295
|
+
AND (type IN ('work_terminal', 'live_retry')
|
|
1296
|
+
OR (type = 'message' AND role = 'assistant'))
|
|
1297
|
+
AND id IN (SELECT id FROM branch)
|
|
1298
|
+
)
|
|
1299
|
+
WHERE diagnostic IS NOT NULL
|
|
1300
|
+
ORDER BY sequence ASC`, lineage.sessionId, works)) {
|
|
1301
|
+
const decoded = decodeLineageDiagnostic(raw);
|
|
1302
|
+
if (decoded._tag === "Failure")
|
|
1303
|
+
throw unavailable("Stored Session diagnostic is invalid");
|
|
1304
|
+
diagnostics.set(decoded.success.work_id, decoded.success.diagnostic);
|
|
1305
|
+
}
|
|
1306
|
+
const cancellations = new Map();
|
|
1307
|
+
for (const { entry } of lineageQuery(owner, lineage, `SELECT ${entryColumns}
|
|
1308
|
+
FROM session_entries
|
|
1309
|
+
WHERE session_id = ? AND state != 'streaming' AND type = 'cancellation'
|
|
1310
|
+
AND json_extract(data_json, '$.targetWorkId')
|
|
1311
|
+
IN (SELECT value FROM json_each(?))
|
|
1312
|
+
AND id IN (SELECT id FROM branch)
|
|
1313
|
+
ORDER BY sequence ASC`, lineage.sessionId, works).map(parseEntryRow))
|
|
1314
|
+
if (entry.kind === "cancellation" && entry.meta !== undefined)
|
|
1315
|
+
cancellations.set(entry.targetWorkId, entry.meta);
|
|
1316
|
+
return Object.freeze({ commands, diagnostics, cancellations });
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Read one complete selected root-to-leaf branch, decoding every row.
|
|
1320
|
+
*
|
|
1321
|
+
* Heap grows with the whole lineage, so no production reader uses this:
|
|
1322
|
+
* clients page with `readLineageWindow`, runs read `readSessionContextPath`.
|
|
1323
|
+
*/
|
|
1324
|
+
export function readSelectedPath(owner, input) {
|
|
1325
|
+
const lineage = readSessionLineage(owner, input);
|
|
1326
|
+
return lineage === undefined
|
|
1327
|
+
? Object.freeze([])
|
|
1328
|
+
: readLineageRange(owner, lineage, 1, lineage.newestSequence);
|
|
1329
|
+
}
|
|
1330
|
+
function segmentQuery(owner, segment, columns, clause, ...bindings) {
|
|
1331
|
+
return lineageQuery(owner, segment.lineage, `SELECT ${columns}
|
|
1332
|
+
FROM session_entries
|
|
1333
|
+
WHERE session_id = ? AND state != 'streaming'
|
|
1334
|
+
AND id IN (SELECT id FROM branch)
|
|
1335
|
+
AND sequence > ?
|
|
1336
|
+
AND (? IS NULL OR work_id = ?
|
|
1337
|
+
OR (type = 'command'
|
|
1338
|
+
AND json_extract(data_json, '$.targetWorkId') = ?))
|
|
1339
|
+
${clause}`, segment.lineage.sessionId, segment.after, segment.work, segment.work, segment.work, ...bindings);
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* First sequence the model context reads: the latest compaction's kept
|
|
1343
|
+
* entry. Zero when nothing compacted, or when that entry is not on these
|
|
1344
|
+
* segments, where `buildSessionContext` also reads the whole path.
|
|
1345
|
+
*/
|
|
1346
|
+
function contextStart(owner, segments) {
|
|
1347
|
+
for (const segment of [...segments].reverse()) {
|
|
1348
|
+
const raw = segmentQuery(owner, segment, "json_extract(data_json, '$.firstKeptEntryId') AS first_kept", "AND type = 'compaction' ORDER BY sequence DESC LIMIT 1")[0];
|
|
1349
|
+
if (raw === undefined)
|
|
1350
|
+
continue;
|
|
1351
|
+
const latest = decodeFirstKept(raw);
|
|
1352
|
+
if (latest._tag === "Failure")
|
|
1353
|
+
throw unavailable("Stored Session compaction boundary is invalid");
|
|
1354
|
+
for (const candidate of segments) {
|
|
1355
|
+
const kept = segmentQuery(owner, candidate, "sequence", "AND id = ?", latest.success.first_kept)[0];
|
|
1356
|
+
if (kept === undefined)
|
|
1357
|
+
continue;
|
|
1358
|
+
const decoded = decodeLineageSequence(kept);
|
|
1359
|
+
if (decoded._tag === "Failure")
|
|
1360
|
+
throw unavailable("Stored Session entry sequence is invalid");
|
|
1361
|
+
return decoded.success.sequence;
|
|
1362
|
+
}
|
|
1363
|
+
return 0;
|
|
1364
|
+
}
|
|
1365
|
+
return 0;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The rows model context and compaction read: the latest compaction's kept
|
|
1369
|
+
* window through the tip, preceded only by the older facts that still decide
|
|
1370
|
+
* it. Those are cancellations that withdrew a command inside the window, and
|
|
1371
|
+
* earlier compactions of the active work, whose recovery runs at most once.
|
|
1372
|
+
*/
|
|
1373
|
+
function readContextRows(owner, segments, workId) {
|
|
1374
|
+
const start = contextStart(owner, segments);
|
|
1375
|
+
let stored = 0;
|
|
1376
|
+
for (const segment of segments) {
|
|
1377
|
+
const measured = decodeContextBytes(segmentQuery(owner, segment, "COALESCE(SUM(LENGTH(CAST(data_json AS BLOB))), 0) AS bytes", "AND sequence >= ?", start)[0]);
|
|
1378
|
+
if (measured._tag === "Failure")
|
|
1379
|
+
throw unavailable("Stored Session context size is invalid");
|
|
1380
|
+
stored += measured.success.bytes;
|
|
1381
|
+
}
|
|
1382
|
+
if (stored > contextByteLimit)
|
|
1383
|
+
throw new ShiitakeError("store", "context_too_large", "This Session's model context is too large to read safely. Branch or fork from an earlier entry to continue.");
|
|
1384
|
+
const window = segments.flatMap((segment) => segmentQuery(owner, segment, entryColumns, "AND sequence >= ? ORDER BY sequence ASC", start).map(parseEntryRow));
|
|
1385
|
+
if (start === 0)
|
|
1386
|
+
return Object.freeze(window);
|
|
1387
|
+
const commands = JSON.stringify(window.flatMap(({ entry }) => entry.kind === "command" ? [entry.workId] : []));
|
|
1388
|
+
const prefix = segments.flatMap((segment) => segmentQuery(owner, segment, entryColumns, `AND sequence < ?
|
|
1389
|
+
AND ((type = 'compaction' AND work_id = ?)
|
|
1390
|
+
OR (type = 'cancellation'
|
|
1391
|
+
AND json_extract(data_json, '$.targetWorkId')
|
|
1392
|
+
IN (SELECT value FROM json_each(?))))
|
|
1393
|
+
ORDER BY sequence ASC`, start, workId, commands).map(parseEntryRow));
|
|
1394
|
+
return Object.freeze([...prefix, ...window]);
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Context rows of the current selected lineage for one accepted work, or,
|
|
1398
|
+
* without a work, the settled context the next run starts from.
|
|
1399
|
+
*/
|
|
1400
|
+
export function readSessionContextPath(owner, input) {
|
|
1401
|
+
const lineage = readSessionLineage(owner, { sessionId: input.sessionId });
|
|
1402
|
+
return lineage === undefined
|
|
1403
|
+
? Object.freeze([])
|
|
1404
|
+
: readContextRows(owner, [{ lineage, after: 0, work: null }], input.workId ?? null);
|
|
1405
|
+
}
|
|
1406
|
+
/** Work cancelled before delivery stays in history but never enters model context. */
|
|
1407
|
+
export function withdrawnWorkIds(path) {
|
|
1408
|
+
// A queued prompt retained after its cancellation is transcript history,
|
|
1409
|
+
// not an instruction delivered to Mule. Context paths carry the older
|
|
1410
|
+
// cancellations that still withdraw a command inside their window.
|
|
1411
|
+
const delivered = new Set();
|
|
1412
|
+
const withdrawn = new Set();
|
|
1413
|
+
for (const { entry } of path) {
|
|
1414
|
+
if (entry.kind === "command")
|
|
1415
|
+
delivered.add(entry.workId);
|
|
1416
|
+
if (entry.kind === "cancellation" && !delivered.has(entry.targetWorkId))
|
|
1417
|
+
withdrawn.add(entry.targetWorkId);
|
|
1418
|
+
}
|
|
1419
|
+
return withdrawn;
|
|
1420
|
+
}
|
|
1421
|
+
export function buildSessionContext(path, activeWorkId) {
|
|
1422
|
+
const withdrawn = withdrawnWorkIds(path);
|
|
1423
|
+
let compaction;
|
|
1424
|
+
for (let index = path.length - 1; index >= 0; index -= 1) {
|
|
1425
|
+
const row = path[index];
|
|
1426
|
+
if (row?.entry.kind === "compaction") {
|
|
1427
|
+
compaction = row;
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const compactionEntry = compaction?.entry.kind === "compaction" ? compaction.entry : undefined;
|
|
1432
|
+
const keptIndex = compactionEntry === undefined
|
|
1433
|
+
? -1
|
|
1434
|
+
: path.findIndex(({ id }) => id === compactionEntry.firstKeptEntryId);
|
|
1435
|
+
const contextPath = keptIndex >= 0 ? path.slice(keptIndex) : path;
|
|
1436
|
+
const visible = contextMessageIds(contextPath, activeWorkId);
|
|
1437
|
+
const messages = [];
|
|
1438
|
+
if (compactionEntry !== undefined && compaction !== undefined) {
|
|
1439
|
+
messages.push(compactionSummaryMessage(compactionEntry.summary, compaction.timestamp));
|
|
1440
|
+
}
|
|
1441
|
+
for (const row of contextPath) {
|
|
1442
|
+
const prompt = promptCommand(row.entry);
|
|
1443
|
+
if (prompt !== undefined && !withdrawn.has(prompt.workId))
|
|
1444
|
+
messages.push(commandContextMessage(prompt));
|
|
1445
|
+
if (row.entry.kind === "operator")
|
|
1446
|
+
messages.push(operatorContextMessage(row.entry));
|
|
1447
|
+
if (row.entry.kind === "message" && visible.has(row.id))
|
|
1448
|
+
messages.push(row.entry.value);
|
|
1449
|
+
}
|
|
1450
|
+
return Object.freeze(messages);
|
|
1451
|
+
}
|
|
1452
|
+
/** Prepare one accepted command against its frozen selected lineage. */
|
|
1453
|
+
export function prepareSessionRun(owner, input) {
|
|
1454
|
+
return owner.transactionSync(() => {
|
|
1455
|
+
let command = readEntryRow(owner, input.sessionId, input.commandFactId);
|
|
1456
|
+
let entryDelivery = "replay";
|
|
1457
|
+
const admission = readSessionCommandAdmission(owner, input.sessionId, input.commandFactId);
|
|
1458
|
+
if (command === undefined && admission !== undefined) {
|
|
1459
|
+
command = appendSessionEntry(owner, {
|
|
1460
|
+
sessionId: admission.sessionId,
|
|
1461
|
+
id: admission.id,
|
|
1462
|
+
entry: admission.entry,
|
|
1463
|
+
});
|
|
1464
|
+
entryDelivery = "new";
|
|
1465
|
+
}
|
|
1466
|
+
if (command?.entry.kind !== "command")
|
|
1467
|
+
throw unavailable("Accepted Session command is unavailable");
|
|
1468
|
+
if (admission !== undefined &&
|
|
1469
|
+
encodeStoredSessionEntry(command.entry) !==
|
|
1470
|
+
encodeStoredSessionEntry(admission.entry))
|
|
1471
|
+
throw unavailable("Accepted Session command disagrees with its admission");
|
|
1472
|
+
const through = locateLineage(owner, input.sessionId, command.id, "selected");
|
|
1473
|
+
if (through.newestId !== command.id)
|
|
1474
|
+
throw unavailable("Accepted Session command lineage is unavailable");
|
|
1475
|
+
// The current claim owns the command. Empty placeholders from a lost
|
|
1476
|
+
// reader carry no recoverable text and must not survive as live progress.
|
|
1477
|
+
owner.sql.exec("DELETE FROM session_entries WHERE session_id = ? AND work_id = ? AND state = 'streaming' AND type = 'message_start'", input.sessionId, command.entry.workId);
|
|
1478
|
+
const work = readSessionEntriesForWork(owner, input.sessionId, command.entry.workId);
|
|
1479
|
+
const workId = command.entry.workId;
|
|
1480
|
+
const current = readSessionLineage(owner, { sessionId: input.sessionId });
|
|
1481
|
+
// The command's own lineage, then only this work's later rows.
|
|
1482
|
+
const continuedPath = readContextRows(owner, current === undefined
|
|
1483
|
+
? [{ lineage: through, after: 0, work: null }]
|
|
1484
|
+
: [
|
|
1485
|
+
{ lineage: through, after: 0, work: null },
|
|
1486
|
+
{ lineage: current, after: command.sequence, work: workId },
|
|
1487
|
+
], workId);
|
|
1488
|
+
const selectedCommand = Object.freeze({
|
|
1489
|
+
...command,
|
|
1490
|
+
entry: command.entry,
|
|
1491
|
+
});
|
|
1492
|
+
return Object.freeze({
|
|
1493
|
+
command: selectedCommand,
|
|
1494
|
+
modelPreference: lineageModelPreference(owner, through),
|
|
1495
|
+
messages: buildSessionContext(continuedPath, command.entry.workId),
|
|
1496
|
+
path: continuedPath,
|
|
1497
|
+
entryDelivery,
|
|
1498
|
+
resume: recoverAssistantTurn(work),
|
|
1499
|
+
abnormalTerminal: abnormalTerminal(work),
|
|
1500
|
+
});
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
/** Append one compaction notice on the selected lineage. */
|
|
1504
|
+
export function appendCompactionNoticeOwned(owner, input) {
|
|
1505
|
+
return owner.transactionSync(() => {
|
|
1506
|
+
const entry = {
|
|
1507
|
+
kind: "compaction_notice",
|
|
1508
|
+
workId: input.workId,
|
|
1509
|
+
trigger: input.trigger,
|
|
1510
|
+
result: input.result,
|
|
1511
|
+
};
|
|
1512
|
+
return appendSessionEntry(owner, {
|
|
1513
|
+
sessionId: input.sessionId,
|
|
1514
|
+
id: sessionIds.fact.generate(),
|
|
1515
|
+
entry: input.diagnostic === undefined
|
|
1516
|
+
? entry
|
|
1517
|
+
: { ...entry, diagnostic: input.diagnostic.slice(0, 200) },
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
/** Atomically append one compaction transition using the selected leaf CAS. */
|
|
1522
|
+
export function appendCompactionTransitionOwned(owner, input) {
|
|
1523
|
+
return owner.transactionSync(() => {
|
|
1524
|
+
const session = readSessionRow(owner, input.sessionId);
|
|
1525
|
+
if (session?.leaf_id !== input.expectedLeafId)
|
|
1526
|
+
throw new ShiitakeError("store", "conflict", "Session leaf changed during compaction");
|
|
1527
|
+
const entry = {
|
|
1528
|
+
kind: "compaction",
|
|
1529
|
+
resumesRun: input.resumesRun ?? false,
|
|
1530
|
+
trigger: input.trigger,
|
|
1531
|
+
tokensAfter: input.tokensAfter,
|
|
1532
|
+
workId: input.workId,
|
|
1533
|
+
firstKeptEntryId: input.firstKeptEntryId,
|
|
1534
|
+
summary: input.summary,
|
|
1535
|
+
tokensBefore: input.tokensBefore,
|
|
1536
|
+
usage: input.usage,
|
|
1537
|
+
contextRevision: input.contextRevision,
|
|
1538
|
+
};
|
|
1539
|
+
return appendSessionEntry(owner, {
|
|
1540
|
+
sessionId: input.sessionId,
|
|
1541
|
+
id: sessionIds.fact.generate(),
|
|
1542
|
+
entry: input.details === undefined
|
|
1543
|
+
? entry
|
|
1544
|
+
: { ...entry, details: input.details },
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
function isPromptParts(content) {
|
|
1549
|
+
return Array.isArray(content);
|
|
1550
|
+
}
|
|
1551
|
+
function promptContent(input) {
|
|
1552
|
+
if (input.images === undefined)
|
|
1553
|
+
return input.text;
|
|
1554
|
+
const content = [];
|
|
1555
|
+
if (input.text.length > 0)
|
|
1556
|
+
content.push({ type: "text", text: input.text });
|
|
1557
|
+
content.push(...input.images);
|
|
1558
|
+
return content;
|
|
1559
|
+
}
|
|
1560
|
+
function samePromptContent(left, right) {
|
|
1561
|
+
if (!isPromptParts(left) || !isPromptParts(right))
|
|
1562
|
+
return left === right;
|
|
1563
|
+
return (left.length === right.length &&
|
|
1564
|
+
left.every((part, index) => {
|
|
1565
|
+
const other = right[index];
|
|
1566
|
+
return part.type === "text"
|
|
1567
|
+
? other?.type === "text" && part.text === other.text
|
|
1568
|
+
: other?.type === "image" &&
|
|
1569
|
+
part.data === other.data &&
|
|
1570
|
+
part.mimeType === other.mimeType;
|
|
1571
|
+
}));
|
|
1572
|
+
}
|
|
1573
|
+
/** Whether a retried request names exactly the work already admitted. */
|
|
1574
|
+
function sameAcceptedWork(previous, input) {
|
|
1575
|
+
if (input.type === "compact")
|
|
1576
|
+
return (isCompactCommand(previous) && previous.instructions === input.instructions);
|
|
1577
|
+
const prompt = promptCommand(previous);
|
|
1578
|
+
return (prompt?.delivery === input.delivery &&
|
|
1579
|
+
samePromptContent(prompt.message.content, promptContent(input)));
|
|
1580
|
+
}
|
|
1581
|
+
/** The durable command entry for one decoded admission. */
|
|
1582
|
+
function acceptedEntry(input, commandFactId, now, contract, targetWorkId,
|
|
1583
|
+
/** Only a prompt the host admitted as a verified Agent reply carries one. */
|
|
1584
|
+
reply) {
|
|
1585
|
+
const identity = {
|
|
1586
|
+
kind: "command",
|
|
1587
|
+
requestId: input.requestId,
|
|
1588
|
+
receiptId: commandFactId,
|
|
1589
|
+
workId: commandFactId,
|
|
1590
|
+
};
|
|
1591
|
+
if (input.type === "compact") {
|
|
1592
|
+
const work = {
|
|
1593
|
+
...identity,
|
|
1594
|
+
work: "compact",
|
|
1595
|
+
timestamp: now,
|
|
1596
|
+
contract,
|
|
1597
|
+
};
|
|
1598
|
+
return input.instructions === undefined
|
|
1599
|
+
? work
|
|
1600
|
+
: { ...work, instructions: input.instructions };
|
|
1601
|
+
}
|
|
1602
|
+
const prompt = {
|
|
1603
|
+
...identity,
|
|
1604
|
+
delivery: input.delivery,
|
|
1605
|
+
message: {
|
|
1606
|
+
role: "user",
|
|
1607
|
+
content: promptContent(input),
|
|
1608
|
+
timestamp: now,
|
|
1609
|
+
},
|
|
1610
|
+
contract,
|
|
1611
|
+
};
|
|
1612
|
+
const targeted = targetWorkId === undefined ? prompt : { ...prompt, targetWorkId };
|
|
1613
|
+
return reply === undefined ? targeted : { ...targeted, reply };
|
|
1614
|
+
}
|
|
1615
|
+
function sameReply(left, right) {
|
|
1616
|
+
return (left?.agentId === right?.agentId &&
|
|
1617
|
+
left?.sessionId === right?.sessionId &&
|
|
1618
|
+
left?.commandId === right?.commandId &&
|
|
1619
|
+
left?.outcome === right?.outcome &&
|
|
1620
|
+
left?.diagnostic === right?.diagnostic);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Write one decoded admission (a prompt or a compaction request) and its one
|
|
1624
|
+
* durable work pointer.
|
|
1625
|
+
*
|
|
1626
|
+
* Only the accepted-command door (`internal/admission-door.ts`) calls this,
|
|
1627
|
+
* inside its owner transaction. A retried request id replays only when the
|
|
1628
|
+
* whole canonical command (kind, delivery, text, images, host metadata,
|
|
1629
|
+
* verified reply and origin) is unchanged.
|
|
1630
|
+
*/
|
|
1631
|
+
export function acceptSessionCommandOwned(owner, sessionId, input, meta, origin, contract, enqueue, targetWorkId, reply) {
|
|
1632
|
+
const previous = readSessionCommandAdmissionByRequest(owner, sessionId, input.requestId);
|
|
1633
|
+
if (previous !== undefined) {
|
|
1634
|
+
if (!sameAcceptedWork(previous.entry, input) ||
|
|
1635
|
+
!sameHostMeta(previous.entry.meta, meta) ||
|
|
1636
|
+
!sameReply(promptCommand(previous.entry)?.reply, reply) ||
|
|
1637
|
+
!equivalentAdmissionOrigin(previous.origin, origin))
|
|
1638
|
+
throw new ShiitakeError("contract", "conflict", "Request id conflicts with an accepted Session command");
|
|
1639
|
+
return acceptanceFromAdmission(previous, "replay");
|
|
1640
|
+
}
|
|
1641
|
+
const commandFactId = sessionIds.fact.generate();
|
|
1642
|
+
ensureSession(owner, sessionId);
|
|
1643
|
+
const now = Date.now();
|
|
1644
|
+
const entry = acceptedEntry(input, commandFactId, now, contract, targetWorkId, reply);
|
|
1645
|
+
owner.sql.exec(`INSERT INTO session_command_admissions
|
|
1646
|
+
(id, session_id, sequence, request_id, work_id, data_json, timestamp,
|
|
1647
|
+
origin_kind, origin_id, origin_correlation)
|
|
1648
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, commandFactId, sessionId, nextAdmissionSequence(owner, sessionId), input.requestId, commandFactId, encodeStoredSessionEntry(withHostMeta(entry, meta)), now, origin.kind, origin.kind === "user" ? null : origin.extensionId, origin.kind === "user" ? null : origin.correlation);
|
|
1649
|
+
const command = readSessionCommandAdmission(owner, sessionId, commandFactId);
|
|
1650
|
+
if (command === undefined)
|
|
1651
|
+
throw unavailable("Accepted Session command admission is unavailable");
|
|
1652
|
+
enqueue?.({
|
|
1653
|
+
jobId: commandFactId,
|
|
1654
|
+
sessionId,
|
|
1655
|
+
commandFactId,
|
|
1656
|
+
});
|
|
1657
|
+
return acceptanceFromAdmission(command, "new");
|
|
1658
|
+
}
|
|
1659
|
+
function acceptanceFromAdmission(row, delivery) {
|
|
1660
|
+
const accepted = Object.freeze({
|
|
1661
|
+
sessionId: row.sessionId,
|
|
1662
|
+
requestId: row.entry.requestId,
|
|
1663
|
+
commandId: row.id,
|
|
1664
|
+
});
|
|
1665
|
+
return Object.freeze({ accepted, command: row, delivery });
|
|
1666
|
+
}
|
|
1667
|
+
function acceptanceFromCancellation(row, delivery, active) {
|
|
1668
|
+
if (row.entry.kind !== "cancellation")
|
|
1669
|
+
throw unavailable("Accepted Session cancellation is unavailable");
|
|
1670
|
+
return Object.freeze({
|
|
1671
|
+
accepted: Object.freeze({
|
|
1672
|
+
commandId: row.entry.targetWorkId,
|
|
1673
|
+
entryId: row.id,
|
|
1674
|
+
}),
|
|
1675
|
+
delivery,
|
|
1676
|
+
active,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
/** Cancel the running turn and every waiting admission in one owner transaction. */
|
|
1680
|
+
export function acceptSessionAbort(owner, sessionId, readQueueHead, record, meta) {
|
|
1681
|
+
return owner.transactionSync(() => {
|
|
1682
|
+
const head = readQueueHead();
|
|
1683
|
+
const activeAdmission = head.state === "running"
|
|
1684
|
+
? readSessionCommandAdmissionByWorkId(owner, sessionId, head.jobId)
|
|
1685
|
+
: undefined;
|
|
1686
|
+
const active = activeAdmission !== undefined
|
|
1687
|
+
? acceptSessionCancellationInTransaction(owner, sessionId, activeAdmission.id, readQueueHead, record, false, meta)
|
|
1688
|
+
: undefined;
|
|
1689
|
+
// Page through owner facts, not the client's bounded queue snapshot. Each
|
|
1690
|
+
// cancellation removes that admission from the next page, including steers
|
|
1691
|
+
// that have no separate Watchdog job yet.
|
|
1692
|
+
// The immutable admission sequence bounds this transaction even if a
|
|
1693
|
+
// future projection fails to remove an admission from the pending page.
|
|
1694
|
+
for (let remaining = nextAdmissionSequence(owner, sessionId); remaining > 0;) {
|
|
1695
|
+
const { commands: pending } = readPendingSessionCommands(owner, sessionId, 64);
|
|
1696
|
+
if (pending.length === 0)
|
|
1697
|
+
return active;
|
|
1698
|
+
remaining -= pending.length;
|
|
1699
|
+
for (const admission of pending) {
|
|
1700
|
+
const cancellation = acceptSessionCancellationInTransaction(owner, sessionId, admission.id, readQueueHead, record, false, meta, true);
|
|
1701
|
+
if (cancellation === undefined)
|
|
1702
|
+
throw unavailable("Pending Session command could not be cancelled");
|
|
1703
|
+
// Keep the exact prompt (including images, attribution and host meta)
|
|
1704
|
+
// available for deliberate resubmission after it leaves the queue.
|
|
1705
|
+
appendSessionEntry(owner, {
|
|
1706
|
+
sessionId,
|
|
1707
|
+
id: admission.id,
|
|
1708
|
+
entry: admission.entry,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
throw unavailable("Session cancellation did not settle its pending admissions");
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Atomically resolve one accepted request and delegate queued work to
|
|
1717
|
+
* Watchdog. `undefined` means it is too late (or the id names nothing): the
|
|
1718
|
+
* command is unknown, has already settled, or, for an `onlyIfPending` cancel,
|
|
1719
|
+
* has already started. Nothing is written in that case.
|
|
1720
|
+
*/
|
|
1721
|
+
export function acceptSessionCancellation(owner, sessionId, commandId, readQueueHead, record, onlyIfPending = false, meta) {
|
|
1722
|
+
return owner.transactionSync(() => acceptSessionCancellationInTransaction(owner, sessionId, commandId, readQueueHead, record, onlyIfPending, meta));
|
|
1723
|
+
}
|
|
1724
|
+
function acceptSessionCancellationInTransaction(owner, sessionId, commandId, readQueueHead, record, onlyIfPending = false, meta, settleMissing = false) {
|
|
1725
|
+
const queueHead = readQueueHead();
|
|
1726
|
+
const command = readSessionCommandAdmission(owner, sessionId, commandId);
|
|
1727
|
+
if (command?.entry.kind !== "command")
|
|
1728
|
+
return undefined;
|
|
1729
|
+
const workEntries = readSessionEntriesForWork(owner, sessionId, command.entry.workId);
|
|
1730
|
+
const recordedCancellation = workEntries.find(({ entry }) => entry.kind === "cancellation");
|
|
1731
|
+
if (recordedCancellation?.entry.kind === "cancellation")
|
|
1732
|
+
return acceptanceFromCancellation(recordedCancellation, "replay", queueHead.state === "running" && queueHead.jobId === command.entry.workId);
|
|
1733
|
+
const active = queueHead.state === "running" && queueHead.jobId === command.entry.workId;
|
|
1734
|
+
// A terminal can precede a blocked compaction handoff. Watchdog still owns
|
|
1735
|
+
// the running job until that handoff settles, so cancellation stays live.
|
|
1736
|
+
if (!active &&
|
|
1737
|
+
workEntries.some(({ entry }) => entry.kind === "run_terminal" || entry.kind === "work_terminal"))
|
|
1738
|
+
return undefined;
|
|
1739
|
+
if (onlyIfPending &&
|
|
1740
|
+
(active || workEntries.some(({ entry }) => entry.kind === "command")))
|
|
1741
|
+
return undefined;
|
|
1742
|
+
if (promptCommand(command.entry)?.delivery === "steer" &&
|
|
1743
|
+
workEntries.some(({ entry }) => entry.kind === "command") &&
|
|
1744
|
+
!active)
|
|
1745
|
+
return undefined;
|
|
1746
|
+
const factId = sessionIds.fact.generate();
|
|
1747
|
+
const decision = record({
|
|
1748
|
+
jobId: command.entry.workId,
|
|
1749
|
+
cancellationId: factId,
|
|
1750
|
+
onlyIfPending,
|
|
1751
|
+
});
|
|
1752
|
+
// A steer has no Watchdog row while it waits at the Mule boundary. Its
|
|
1753
|
+
// owner fact is still cancellable, with a terminal recorded below. Cancel-all
|
|
1754
|
+
// also settles orphan admissions whose scheduler row is missing.
|
|
1755
|
+
if (decision.status === "missing" &&
|
|
1756
|
+
promptCommand(command.entry)?.delivery !== "steer" &&
|
|
1757
|
+
!settleMissing)
|
|
1758
|
+
return undefined;
|
|
1759
|
+
// Watchdog refuses an `onlyIfQueued` cancel once the job has been claimed.
|
|
1760
|
+
if (decision.status === "conflict" && onlyIfPending)
|
|
1761
|
+
return undefined;
|
|
1762
|
+
if (decision.status !== "recorded" && decision.status !== "missing")
|
|
1763
|
+
throw new ShiitakeError("store", "conflict", "Session cancellation identity conflicts");
|
|
1764
|
+
const cancellation = appendSessionEntry(owner, {
|
|
1765
|
+
sessionId,
|
|
1766
|
+
id: factId,
|
|
1767
|
+
entry: withHostMeta({
|
|
1768
|
+
kind: "cancellation",
|
|
1769
|
+
requestId: factId,
|
|
1770
|
+
receiptId: factId,
|
|
1771
|
+
workId: command.entry.workId,
|
|
1772
|
+
targetWorkId: command.entry.workId,
|
|
1773
|
+
}, meta),
|
|
1774
|
+
});
|
|
1775
|
+
if (decision.status === "missing")
|
|
1776
|
+
appendSessionEntry(owner, {
|
|
1777
|
+
sessionId,
|
|
1778
|
+
id: sessionIds.fact.generate(),
|
|
1779
|
+
entry: {
|
|
1780
|
+
kind: "work_terminal",
|
|
1781
|
+
workId: command.entry.workId,
|
|
1782
|
+
outcome: "cancelled",
|
|
1783
|
+
},
|
|
1784
|
+
});
|
|
1785
|
+
return acceptanceFromCancellation(cancellation, "new", active);
|
|
1786
|
+
}
|
|
1787
|
+
/** Move the durable leaf to an existing entry in the same Session. */
|
|
1788
|
+
export function selectSessionBranch(owner, input) {
|
|
1789
|
+
owner.transactionSync(() => {
|
|
1790
|
+
input.assertIdleSession?.(input.sessionId);
|
|
1791
|
+
const session = readSessionRow(owner, input.sessionId);
|
|
1792
|
+
if (session === undefined)
|
|
1793
|
+
throw unavailable("Session is unavailable for branch selection");
|
|
1794
|
+
validateCompleteSessionLeaf(owner, session);
|
|
1795
|
+
const target = readEntryRow(owner, input.sessionId, input.entryId);
|
|
1796
|
+
if (target?.state !== "complete")
|
|
1797
|
+
throw unavailable("Selected branch entry is unavailable");
|
|
1798
|
+
const now = Date.now();
|
|
1799
|
+
owner.sql.exec("UPDATE sessions SET leaf_id = ?, updated_at = ? WHERE id = ?", target.id, now, session.id);
|
|
1800
|
+
touchSessionInbox(owner, {
|
|
1801
|
+
sessionId: session.id,
|
|
1802
|
+
activityAt: now,
|
|
1803
|
+
title: inboxTitle(owner, session.id),
|
|
1804
|
+
});
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
/**
|
|
1808
|
+
* Record one operator or party line on an idle Session. The request id is
|
|
1809
|
+
* the idempotency key: a replay of the same line is `false`, another line
|
|
1810
|
+
* under the same id is a conflict.
|
|
1811
|
+
*/
|
|
1812
|
+
export function appendOperatorEntry(owner, input) {
|
|
1813
|
+
return owner.transactionSync(() => {
|
|
1814
|
+
if (readSessionRow(owner, input.sessionId) === undefined)
|
|
1815
|
+
throw unavailable("Session is unavailable for an operator entry");
|
|
1816
|
+
const existing = owner.sql
|
|
1817
|
+
.exec("SELECT id FROM session_entries WHERE session_id = ? AND request_id = ?", input.sessionId, input.requestId)
|
|
1818
|
+
.toArray()[0];
|
|
1819
|
+
if (existing !== undefined) {
|
|
1820
|
+
const decoded = decodeSessionIdRow(existing);
|
|
1821
|
+
if (decoded._tag === "Failure")
|
|
1822
|
+
throw unavailable("Stored Session entry identity is invalid");
|
|
1823
|
+
const row = readEntryRow(owner, input.sessionId, decoded.success.id);
|
|
1824
|
+
if (row?.entry.kind === "operator" &&
|
|
1825
|
+
row.entry.author === input.author &&
|
|
1826
|
+
sameHostMeta(row.entry.meta, input.meta) &&
|
|
1827
|
+
row.entry.message.content === input.text)
|
|
1828
|
+
return false;
|
|
1829
|
+
throw new ShiitakeError("store", "conflict", "Request id already names another Session entry");
|
|
1830
|
+
}
|
|
1831
|
+
input.assertIdleSession(input.sessionId);
|
|
1832
|
+
const entry = {
|
|
1833
|
+
kind: "operator",
|
|
1834
|
+
workId: null,
|
|
1835
|
+
requestId: input.requestId,
|
|
1836
|
+
author: input.author,
|
|
1837
|
+
message: { role: "user", content: input.text, timestamp: Date.now() },
|
|
1838
|
+
};
|
|
1839
|
+
appendSessionEntry(owner, {
|
|
1840
|
+
sessionId: input.sessionId,
|
|
1841
|
+
id: sessionIds.fact.generate(),
|
|
1842
|
+
entry: withHostMeta(entry, input.meta),
|
|
1843
|
+
});
|
|
1844
|
+
return true;
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
function readSessionRows(owner, sessionId) {
|
|
1848
|
+
return owner.sql
|
|
1849
|
+
.exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
|
|
1850
|
+
request_id, work_id, data_json, timestamp, completed_at
|
|
1851
|
+
FROM session_entries
|
|
1852
|
+
WHERE session_id = ? AND state != 'streaming'
|
|
1853
|
+
ORDER BY sequence ASC`, sessionId)
|
|
1854
|
+
.toArray()
|
|
1855
|
+
.map(parseEntryRow);
|
|
1856
|
+
}
|
|
1857
|
+
/** Link every stored row into a forest, retaining rows with no parent. */
|
|
1858
|
+
function buildMutableSessionForest(rows) {
|
|
1859
|
+
const mutableNodes = new Map();
|
|
1860
|
+
for (const row of rows)
|
|
1861
|
+
mutableNodes.set(row.id, { row, children: [] });
|
|
1862
|
+
const roots = [];
|
|
1863
|
+
for (const row of rows) {
|
|
1864
|
+
const node = mutableNodes.get(row.id);
|
|
1865
|
+
if (node === undefined)
|
|
1866
|
+
throw unavailable("Session tree row is unavailable");
|
|
1867
|
+
if (row.parentId === null ||
|
|
1868
|
+
row.parentId === row.id ||
|
|
1869
|
+
!mutableNodes.has(row.parentId)) {
|
|
1870
|
+
roots.push(node);
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
const parent = mutableNodes.get(row.parentId);
|
|
1874
|
+
if (parent === undefined)
|
|
1875
|
+
throw unavailable("Session tree parent is unavailable");
|
|
1876
|
+
parent.children.push(node);
|
|
1877
|
+
}
|
|
1878
|
+
roots.sort((left, right) => left.row.sequence - right.row.sequence);
|
|
1879
|
+
return roots;
|
|
1880
|
+
}
|
|
1881
|
+
function freezeSessionForest(roots) {
|
|
1882
|
+
const frozenNodes = new Map();
|
|
1883
|
+
const pending = roots.map((node) => ({ node, expanded: false }));
|
|
1884
|
+
while (pending.length > 0) {
|
|
1885
|
+
const current = pending.pop();
|
|
1886
|
+
if (current === undefined)
|
|
1887
|
+
throw unavailable("Session tree is unavailable");
|
|
1888
|
+
if (!current.expanded) {
|
|
1889
|
+
pending.push({ node: current.node, expanded: true });
|
|
1890
|
+
current.node.children.sort((left, right) => left.row.sequence - right.row.sequence);
|
|
1891
|
+
for (let index = current.node.children.length - 1; index >= 0; index -= 1) {
|
|
1892
|
+
const child = current.node.children[index];
|
|
1893
|
+
if (child !== undefined)
|
|
1894
|
+
pending.push({ node: child, expanded: false });
|
|
1895
|
+
}
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const children = current.node.children.map((child) => {
|
|
1899
|
+
const frozen = frozenNodes.get(child.row.id);
|
|
1900
|
+
if (frozen === undefined)
|
|
1901
|
+
throw unavailable("Session tree child is unavailable");
|
|
1902
|
+
return frozen;
|
|
1903
|
+
});
|
|
1904
|
+
frozenNodes.set(current.node.row.id, Object.freeze({
|
|
1905
|
+
row: current.node.row,
|
|
1906
|
+
children: Object.freeze(children),
|
|
1907
|
+
}));
|
|
1908
|
+
}
|
|
1909
|
+
return Object.freeze(roots.map((root) => {
|
|
1910
|
+
const frozen = frozenNodes.get(root.row.id);
|
|
1911
|
+
if (frozen === undefined)
|
|
1912
|
+
throw unavailable("Session tree root is unavailable");
|
|
1913
|
+
return frozen;
|
|
1914
|
+
}));
|
|
1915
|
+
}
|
|
1916
|
+
/** Read the complete retained Session tree, including orphan roots. */
|
|
1917
|
+
export function readSessionTree(owner, input) {
|
|
1918
|
+
if (readSessionRow(owner, input.sessionId) === undefined)
|
|
1919
|
+
return [];
|
|
1920
|
+
const rows = readSessionRows(owner, input.sessionId);
|
|
1921
|
+
return freezeSessionForest(buildMutableSessionForest(rows));
|
|
1922
|
+
}
|
|
1923
|
+
/** Fork exactly one Session root-to-entry lineage with fresh row identities. */
|
|
1924
|
+
export function forkSessionAt(owner, input) {
|
|
1925
|
+
return owner.transactionSync(() => {
|
|
1926
|
+
input.assertIdleSession?.(input.sessionId);
|
|
1927
|
+
if (readSessionRow(owner, input.sessionId) === undefined)
|
|
1928
|
+
throw unavailable("Source Session is unavailable");
|
|
1929
|
+
const source = locateLineage(owner, input.sessionId, input.entryId, "fork");
|
|
1930
|
+
const newSessionId = sessionIds.session.generate();
|
|
1931
|
+
const now = Date.now();
|
|
1932
|
+
owner.sql.exec(`INSERT INTO sessions
|
|
1933
|
+
(id, cwd, parent_session, leaf_id, created_at, updated_at, metadata_json)
|
|
1934
|
+
VALUES (?, '/', ?, NULL, ?, ?, NULL)`, newSessionId, input.sessionId, now, now);
|
|
1935
|
+
copySessionGrants(owner, input.sessionId, newSessionId);
|
|
1936
|
+
let parentId = null;
|
|
1937
|
+
let leafId = null;
|
|
1938
|
+
let sequence = 1;
|
|
1939
|
+
// Only identities accumulate; entry data is copied one bounded window
|
|
1940
|
+
// at a time.
|
|
1941
|
+
const idMap = new Map();
|
|
1942
|
+
let copied = 0;
|
|
1943
|
+
for (;;) {
|
|
1944
|
+
const window = readLineageWindow(owner, source, {
|
|
1945
|
+
after: copied,
|
|
1946
|
+
from: "oldest",
|
|
1947
|
+
rows: forkWindowRows,
|
|
1948
|
+
});
|
|
1949
|
+
for (const row of window.rows) {
|
|
1950
|
+
const id = sessionIds.fact.generate();
|
|
1951
|
+
idMap.set(row.id, id);
|
|
1952
|
+
const entry = row.entry.kind === "compaction"
|
|
1953
|
+
? {
|
|
1954
|
+
...row.entry,
|
|
1955
|
+
firstKeptEntryId: idMap.get(row.entry.firstKeptEntryId) ??
|
|
1956
|
+
(() => {
|
|
1957
|
+
throw unavailable("Fork compaction boundary is unavailable");
|
|
1958
|
+
})(),
|
|
1959
|
+
}
|
|
1960
|
+
: row.entry;
|
|
1961
|
+
owner.sql.exec(`INSERT INTO session_entries
|
|
1962
|
+
(id, session_id, parent_id, sequence, type, role, state,
|
|
1963
|
+
request_id, work_id, data_json, timestamp, completed_at)
|
|
1964
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, newSessionId, parentId, sequence, entry.kind, roleFor(entry), row.state, requestIdFor(entry), entry.workId, encodeStoredSessionEntry(entry), row.timestamp, row.completedAt);
|
|
1965
|
+
parentId = id;
|
|
1966
|
+
leafId = id;
|
|
1967
|
+
sequence += 1;
|
|
1968
|
+
copied = row.sequence;
|
|
1969
|
+
}
|
|
1970
|
+
if (!window.more)
|
|
1971
|
+
break;
|
|
1972
|
+
}
|
|
1973
|
+
if (leafId === null)
|
|
1974
|
+
throw unavailable("Fork path is empty");
|
|
1975
|
+
owner.sql.exec("UPDATE sessions SET leaf_id = ?, updated_at = ? WHERE id = ?", leafId, now, newSessionId);
|
|
1976
|
+
const current = readSessionLineage(owner, { sessionId: input.sessionId });
|
|
1977
|
+
for (const entry of forkDocumentEntries(owner, (extensionId, name) => readLineageDocument(owner, current, extensionId, name)))
|
|
1978
|
+
appendSessionEntry(owner, {
|
|
1979
|
+
sessionId: newSessionId,
|
|
1980
|
+
id: sessionIds.fact.generate(),
|
|
1981
|
+
entry,
|
|
1982
|
+
});
|
|
1983
|
+
touchSessionInbox(owner, {
|
|
1984
|
+
sessionId: newSessionId,
|
|
1985
|
+
activityAt: now,
|
|
1986
|
+
title: inboxTitle(owner, newSessionId),
|
|
1987
|
+
});
|
|
1988
|
+
return newSessionId;
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
/** Read document state from the selected transcript branch. */
|
|
1992
|
+
export function readSessionDocument(owner, sessionId, extensionId, name) {
|
|
1993
|
+
if (readSessionRow(owner, sessionId) === undefined)
|
|
1994
|
+
throw unavailable("Session document owner is unavailable");
|
|
1995
|
+
return readLineageDocument(owner, readSessionLineage(owner, { sessionId }), extensionId, name);
|
|
1996
|
+
}
|
|
1997
|
+
/** Append a document snapshot under the same transaction as its producing hook. */
|
|
1998
|
+
export function writeSessionDocument(owner, sessionId, extensionId, name, valueJson) {
|
|
1999
|
+
if (readSessionRow(owner, sessionId) === undefined)
|
|
2000
|
+
throw unavailable("Session document owner is unavailable");
|
|
2001
|
+
appendSessionEntry(owner, {
|
|
2002
|
+
sessionId,
|
|
2003
|
+
id: sessionIds.fact.generate(),
|
|
2004
|
+
entry: { kind: "document", workId: null, extensionId, name, valueJson },
|
|
2005
|
+
});
|
|
2006
|
+
}
|