@frockbot/cloudflare 0.0.0 → 0.7.291
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { decodeRoutineRecordV1 } from "@frockbot/app/routines/records";
|
|
2
|
+
import {
|
|
3
|
+
ROUTINE_PREFIX,
|
|
4
|
+
routineFireKeyV1,
|
|
5
|
+
routineHookKeyRecordV1,
|
|
6
|
+
routineQueuePrefixV1,
|
|
7
|
+
routineRunPrefixV1,
|
|
8
|
+
routineScheduleKeyV1,
|
|
9
|
+
} from "@frockbot/app/routines/storage-keys";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Disposable pre-user cleanup for Routine records written with retired
|
|
13
|
+
* trigger shapes. Keep readable Routines; remove each unreadable authority
|
|
14
|
+
* record and the state that can cause it to fire.
|
|
15
|
+
*/
|
|
16
|
+
export async function cleanRetiredRoutineStateV1(
|
|
17
|
+
storage: DurableObjectStorage,
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const receiptKey = "maintenance:retired-routine-state:2026-09-16";
|
|
20
|
+
await storage.transaction(async (tx) => {
|
|
21
|
+
if (await tx.get(receiptKey)) return;
|
|
22
|
+
const retired: string[] = [];
|
|
23
|
+
const routines = await tx.list<unknown>({ prefix: ROUTINE_PREFIX });
|
|
24
|
+
for (const [key, value] of routines) {
|
|
25
|
+
try {
|
|
26
|
+
decodeRoutineRecordV1(value);
|
|
27
|
+
} catch {
|
|
28
|
+
retired.push(key.slice(ROUTINE_PREFIX.length));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let deleted = 0;
|
|
32
|
+
for (const routineId of retired) {
|
|
33
|
+
const direct = [
|
|
34
|
+
`${ROUTINE_PREFIX}${routineId}`,
|
|
35
|
+
routineHookKeyRecordV1(routineId),
|
|
36
|
+
routineScheduleKeyV1(routineId),
|
|
37
|
+
routineFireKeyV1(routineId),
|
|
38
|
+
];
|
|
39
|
+
deleted += await tx.delete(direct);
|
|
40
|
+
for (const prefix of [
|
|
41
|
+
routineQueuePrefixV1(routineId),
|
|
42
|
+
routineRunPrefixV1(routineId),
|
|
43
|
+
]) {
|
|
44
|
+
for (;;) {
|
|
45
|
+
const keys = [...(await tx.list({ prefix, limit: 128 })).keys()];
|
|
46
|
+
if (keys.length === 0) break;
|
|
47
|
+
deleted += await tx.delete(keys);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await tx.put(receiptKey, {
|
|
52
|
+
at: new Date().toISOString(),
|
|
53
|
+
retired: retired.length,
|
|
54
|
+
deleted,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The transcript-index seam, bound in production.
|
|
2
|
+
//
|
|
3
|
+
// Two directions cross a Durable Object boundary here, and both are decoded on
|
|
4
|
+
// arrival — "Cross-runtime communication uses narrow, versioned DTOs, and
|
|
5
|
+
// every inbound value is decoded at its seam". An answer from another Durable
|
|
6
|
+
// Object is inbound, so it is decoded because of what it is, never trusted
|
|
7
|
+
// because of where it came from.
|
|
8
|
+
//
|
|
9
|
+
// * `createUserSearchSinkV1` is the Bot Durable Object's half: a settled
|
|
10
|
+
// Turn's rows go to the User Durable Object, which is the authority for
|
|
11
|
+
// User-scoped state. It is deliberately fire-and-forget at the call site —
|
|
12
|
+
// the run is already durable before this runs, and `rebuildSearchIndex`
|
|
13
|
+
// reconstructs anything a failed projection missed.
|
|
14
|
+
// * `createBotSearchRowPageV1` is the User Durable Object's half: one page of
|
|
15
|
+
// a Bot's rows, projected from that Bot's own stored runs, which is what
|
|
16
|
+
// makes the index rebuildable rather than authoritative.
|
|
17
|
+
import {
|
|
18
|
+
searchRowsFromClientRunV1,
|
|
19
|
+
type SearchRowPageV1,
|
|
20
|
+
type SearchRowV1,
|
|
21
|
+
type SearchSinkV1,
|
|
22
|
+
} from "@frockbot/app/search";
|
|
23
|
+
import { decodeClientRunPageV1 } from "@frockbot/app/shell/run-protocol";
|
|
24
|
+
|
|
25
|
+
/** The User Durable Object's search RPC surface, as the Bot object calls it. */
|
|
26
|
+
export interface UserSearchRpc {
|
|
27
|
+
indexSearchRows(input: unknown): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The Bot Durable Object's projection RPC, as the User object calls it. */
|
|
31
|
+
export interface BotSearchRpc {
|
|
32
|
+
projectSearchRows(input: unknown): Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `SearchSinkV1` over the User Durable Object.
|
|
37
|
+
*
|
|
38
|
+
* A failed index write is swallowed on purpose. The Turn it belongs to has
|
|
39
|
+
* already reached its durable terminal state in the Bot Durable Object; making
|
|
40
|
+
* the User object's availability a condition of a Turn settling would put a
|
|
41
|
+
* derived projection on the critical path of the thing it is derived from.
|
|
42
|
+
* The cost of the failure is bounded and repairable: those rows are missing
|
|
43
|
+
* until a rebuild, and the index state the UI shows says so.
|
|
44
|
+
*/
|
|
45
|
+
export function createUserSearchSinkV1(
|
|
46
|
+
rpc: UserSearchRpc,
|
|
47
|
+
identity: { userId: string; botId: string },
|
|
48
|
+
): SearchSinkV1 {
|
|
49
|
+
return {
|
|
50
|
+
async indexRows(rows) {
|
|
51
|
+
if (rows.length === 0) return;
|
|
52
|
+
const owned = rows.filter((row) => row.botId === identity.botId);
|
|
53
|
+
if (owned.length === 0) return;
|
|
54
|
+
await rpc.indexSearchRows({
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
userId: identity.userId,
|
|
57
|
+
botId: identity.botId,
|
|
58
|
+
rows: owned,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One page of a Bot's rows, projected from the run list the Shell Package
|
|
66
|
+
* already exposes.
|
|
67
|
+
*
|
|
68
|
+
* The run list is the same projection the WebUI reads, so the rows a rebuild
|
|
69
|
+
* writes are the rows a settlement would have written — the property the whole
|
|
70
|
+
* "the index is disposable" claim rests on.
|
|
71
|
+
*/
|
|
72
|
+
export function createBotSearchRowPageV1(
|
|
73
|
+
botId: string,
|
|
74
|
+
runList: unknown,
|
|
75
|
+
): SearchRowPageV1 {
|
|
76
|
+
const page = decodeClientRunPageV1(runList);
|
|
77
|
+
const rows: SearchRowV1[] = page.runs.flatMap((run) =>
|
|
78
|
+
searchRowsFromClientRunV1(botId, run),
|
|
79
|
+
);
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
botId,
|
|
83
|
+
rows,
|
|
84
|
+
...(page.page.truncated && page.page.nextCursor
|
|
85
|
+
? { nextCursor: page.page.nextCursor }
|
|
86
|
+
: {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Disposable cleanup for the retired sidebar label.
|
|
2
|
+
//
|
|
3
|
+
// A Bot's profile no longer has a `label`, and its exact decoder refuses one.
|
|
4
|
+
// The Bot's settings record loses it, with the revision moved so an open
|
|
5
|
+
// settings surface fences on the record it now reads. Every run keeps the
|
|
6
|
+
// settings it was admitted under twice — the configuration snapshot and the
|
|
7
|
+
// prepared inputs — and both are decoded whenever the run is read, so each
|
|
8
|
+
// loses the label too. A Group Chat's arrangement had the same label; the
|
|
9
|
+
// User's group list and the receipts that replay a group record lose it, and
|
|
10
|
+
// the list's revision moves. The walks are once per object, and page rather
|
|
11
|
+
// than loading the archive into one value.
|
|
12
|
+
|
|
13
|
+
import { BOT_CONFIGURATION_KEY } from "@frockbot/app/settings/bot";
|
|
14
|
+
import { RUN_PREFIX } from "@frockbot/core/durable";
|
|
15
|
+
|
|
16
|
+
const BOT_RECEIPT = "maintenance:bot-label-removal:2026-09-25";
|
|
17
|
+
const USER_RECEIPT = "maintenance:group-label-removal:2026-09-25";
|
|
18
|
+
const GROUP_LIST_KEY = "group-chat:list:v1";
|
|
19
|
+
const GROUP_RECEIPT_PREFIX = "group-chat:receipt:";
|
|
20
|
+
const PAGE = 50;
|
|
21
|
+
|
|
22
|
+
interface CleanupStorage {
|
|
23
|
+
get(key: string): Promise<unknown>;
|
|
24
|
+
put(key: string, value: unknown): Promise<void>;
|
|
25
|
+
list(options: {
|
|
26
|
+
prefix?: string;
|
|
27
|
+
limit?: number;
|
|
28
|
+
start?: string;
|
|
29
|
+
}): Promise<Map<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Plain = Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
function record(value: unknown): Plain | undefined {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
36
|
+
return undefined;
|
|
37
|
+
return value as Plain;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function page(
|
|
41
|
+
storage: CleanupStorage,
|
|
42
|
+
prefix: string,
|
|
43
|
+
visit: (key: string, value: unknown) => Promise<void>,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
let start: string | undefined;
|
|
46
|
+
for (;;) {
|
|
47
|
+
const listed = await storage.list({
|
|
48
|
+
prefix,
|
|
49
|
+
limit: PAGE,
|
|
50
|
+
...(start ? { start } : {}),
|
|
51
|
+
});
|
|
52
|
+
if (listed.size === 0) return;
|
|
53
|
+
let last = "";
|
|
54
|
+
for (const [key, value] of listed) {
|
|
55
|
+
last = key;
|
|
56
|
+
if (start !== undefined && key === start) continue;
|
|
57
|
+
await visit(key, value);
|
|
58
|
+
}
|
|
59
|
+
if (listed.size < PAGE) return;
|
|
60
|
+
start = `${last}\0`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `value` with `label` gone from the record at `path`, or undefined. */
|
|
65
|
+
function withoutLabelAt(value: unknown, path: readonly string[]): unknown {
|
|
66
|
+
const outer = record(value);
|
|
67
|
+
if (!outer) return undefined;
|
|
68
|
+
if (path.length === 0) {
|
|
69
|
+
if (!Object.hasOwn(outer, "label")) return undefined;
|
|
70
|
+
const { label: _retired, ...rest } = outer;
|
|
71
|
+
return rest;
|
|
72
|
+
}
|
|
73
|
+
const [head, ...tail] = path as [string, ...string[]];
|
|
74
|
+
const inner = withoutLabelAt(outer[head], tail);
|
|
75
|
+
return inner === undefined ? undefined : { ...outer, [head]: inner };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Applies each path in turn; undefined when none of them carried a label. */
|
|
79
|
+
function withoutLabels(
|
|
80
|
+
value: unknown,
|
|
81
|
+
paths: readonly (readonly string[])[],
|
|
82
|
+
): unknown {
|
|
83
|
+
let current = value;
|
|
84
|
+
let changed = false;
|
|
85
|
+
for (const path of paths) {
|
|
86
|
+
const next = withoutLabelAt(current, path);
|
|
87
|
+
if (next === undefined) continue;
|
|
88
|
+
current = next;
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
return changed ? current : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const RUN_SETTINGS_PATHS = [
|
|
95
|
+
["configurationSnapshot", "profile"],
|
|
96
|
+
["preparedInputs", "bot", "settings", "profile"],
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
export async function cleanBotLabelV1(storage: CleanupStorage): Promise<void> {
|
|
100
|
+
if (await storage.get(BOT_RECEIPT)) return;
|
|
101
|
+
let settings = false;
|
|
102
|
+
const stored = record(await storage.get(BOT_CONFIGURATION_KEY));
|
|
103
|
+
const cleaned = record(withoutLabelAt(stored, ["profile"]));
|
|
104
|
+
if (stored && cleaned && typeof stored.revision === "number") {
|
|
105
|
+
await storage.put(BOT_CONFIGURATION_KEY, {
|
|
106
|
+
...cleaned,
|
|
107
|
+
revision: stored.revision + 1,
|
|
108
|
+
});
|
|
109
|
+
settings = true;
|
|
110
|
+
}
|
|
111
|
+
let runs = 0;
|
|
112
|
+
await page(storage, RUN_PREFIX, async (key, value) => {
|
|
113
|
+
const run = withoutLabels(value, RUN_SETTINGS_PATHS);
|
|
114
|
+
if (run === undefined) return;
|
|
115
|
+
runs += 1;
|
|
116
|
+
await storage.put(key, run);
|
|
117
|
+
});
|
|
118
|
+
await storage.put(BOT_RECEIPT, {
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
settings,
|
|
121
|
+
runs,
|
|
122
|
+
completedAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const GROUP_RECEIPT_PATHS = [
|
|
127
|
+
["result", "receipt", "group"],
|
|
128
|
+
["result", "change", "context", "group"],
|
|
129
|
+
] as const;
|
|
130
|
+
|
|
131
|
+
export async function cleanGroupChatLabelsV1(
|
|
132
|
+
storage: CleanupStorage,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
if (await storage.get(USER_RECEIPT)) return;
|
|
135
|
+
let groups = 0;
|
|
136
|
+
const list = record(await storage.get(GROUP_LIST_KEY));
|
|
137
|
+
if (list && Array.isArray(list.groups) && typeof list.revision === "number") {
|
|
138
|
+
const arranged = list.groups.map((group) => {
|
|
139
|
+
const cleaned = withoutLabelAt(group, []);
|
|
140
|
+
if (cleaned === undefined) return group;
|
|
141
|
+
groups += 1;
|
|
142
|
+
return cleaned;
|
|
143
|
+
});
|
|
144
|
+
if (groups > 0) {
|
|
145
|
+
await storage.put(GROUP_LIST_KEY, {
|
|
146
|
+
...list,
|
|
147
|
+
revision: list.revision + 1,
|
|
148
|
+
groups: arranged,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
let receipts = 0;
|
|
153
|
+
await page(storage, GROUP_RECEIPT_PREFIX, async (key, value) => {
|
|
154
|
+
const receipt = withoutLabels(value, GROUP_RECEIPT_PATHS);
|
|
155
|
+
if (receipt === undefined) return;
|
|
156
|
+
receipts += 1;
|
|
157
|
+
await storage.put(key, receipt);
|
|
158
|
+
});
|
|
159
|
+
await storage.put(USER_RECEIPT, {
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
groups,
|
|
162
|
+
receipts,
|
|
163
|
+
completedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Drops Skill index records this process cannot decode.
|
|
2
|
+
//
|
|
3
|
+
// There is no legacy shape. A record that fails the current decoder is
|
|
4
|
+
// disposable test state, not something a Turn should try to interpret. The
|
|
5
|
+
// receipt makes the walk once per object. Reseeding the index from object
|
|
6
|
+
// bytes is separate and also receipted: startup never scans Skill files.
|
|
7
|
+
|
|
8
|
+
import { SKILL_INDEX_PREFIX } from "@frockbot/core/durable";
|
|
9
|
+
import { decodeSkillMetadataIndexV1 } from "@frockbot/app/skills/metadata-index";
|
|
10
|
+
|
|
11
|
+
const RECEIPT = "maintenance:skill-index-decode:2026-09-22";
|
|
12
|
+
const PAGE = 50;
|
|
13
|
+
|
|
14
|
+
interface CleanupStorage {
|
|
15
|
+
get(key: string): Promise<unknown>;
|
|
16
|
+
put(key: string, value: unknown): Promise<void>;
|
|
17
|
+
delete(key: string): Promise<boolean | void>;
|
|
18
|
+
list(options: {
|
|
19
|
+
prefix?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
start?: string;
|
|
22
|
+
}): Promise<Map<string, unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function cleanUndecodableSkillIndexesV1(
|
|
26
|
+
storage: CleanupStorage,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
if (await storage.get(RECEIPT)) return;
|
|
29
|
+
let start: string | undefined;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const listed = await storage.list({
|
|
32
|
+
prefix: SKILL_INDEX_PREFIX,
|
|
33
|
+
limit: PAGE,
|
|
34
|
+
...(start ? { start } : {}),
|
|
35
|
+
});
|
|
36
|
+
if (listed.size === 0) break;
|
|
37
|
+
let last = "";
|
|
38
|
+
for (const [key, value] of listed) {
|
|
39
|
+
last = key;
|
|
40
|
+
if (start !== undefined && key === start) continue;
|
|
41
|
+
if (!key.includes(":current:") && !key.includes(":rev:")) continue;
|
|
42
|
+
if (key.includes(":rev:")) continue;
|
|
43
|
+
try {
|
|
44
|
+
decodeSkillMetadataIndexV1(value);
|
|
45
|
+
} catch {
|
|
46
|
+
await storage.delete(key);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (listed.size < PAGE) break;
|
|
50
|
+
start = `${last}\0`;
|
|
51
|
+
}
|
|
52
|
+
await storage.put(RECEIPT, { schemaVersion: 1 });
|
|
53
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Disposable cleanup for the state Supersede left behind when steering
|
|
2
|
+
// replaced it.
|
|
3
|
+
//
|
|
4
|
+
// A run that settled `superseded` becomes `cancelled`, which is what it now
|
|
5
|
+
// reads as: it stopped because the person moved on, and its stop time is the
|
|
6
|
+
// moment that happened. Every run loses the two supersede fields and the
|
|
7
|
+
// model-intent flag only supersede read. The single waiting slot joins the
|
|
8
|
+
// user queue in its place. Queued `superseded-turn` inputs, and the drain
|
|
9
|
+
// receipts that carried one, are removed: nothing decodes that kind any more,
|
|
10
|
+
// and a receipt is only read back by the Turn that drained it. Projected rows
|
|
11
|
+
// and replayable updates carry whole runs to clients, whose schema no longer
|
|
12
|
+
// has the status, so they are rewritten the same way. The walk is once per
|
|
13
|
+
// Bot; it pages rather than loading the archive into one value.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
CONVERSATION_ROW_PREFIX,
|
|
17
|
+
CONVERSATION_UPDATE_PREFIX,
|
|
18
|
+
pendingUserRunKey,
|
|
19
|
+
RUN_PREFIX,
|
|
20
|
+
} from "@frockbot/core/durable";
|
|
21
|
+
import {
|
|
22
|
+
ROUTINE_DRAIN_PREFIX,
|
|
23
|
+
ROUTINE_WAKE_PREFIX,
|
|
24
|
+
} from "@frockbot/app/routines/storage-keys";
|
|
25
|
+
|
|
26
|
+
const RECEIPT = "maintenance:supersede-removal:2026-09-23";
|
|
27
|
+
const RETIRED_PENDING_RUN_KEY = "pending-run";
|
|
28
|
+
const RETIRED_INPUT_KIND = "superseded-turn";
|
|
29
|
+
const RETIRED_RUN_FIELDS = ["supersededAt", "supersededBy", "hasModelIntent"];
|
|
30
|
+
const PAGE = 50;
|
|
31
|
+
|
|
32
|
+
interface CleanupStorage {
|
|
33
|
+
get(key: string): Promise<unknown>;
|
|
34
|
+
put(key: string, value: unknown): Promise<void>;
|
|
35
|
+
delete(key: string): Promise<boolean>;
|
|
36
|
+
list(options: {
|
|
37
|
+
prefix?: string;
|
|
38
|
+
limit?: number;
|
|
39
|
+
start?: string;
|
|
40
|
+
}): Promise<Map<string, unknown>>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
44
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
45
|
+
return undefined;
|
|
46
|
+
return value as Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function page(
|
|
50
|
+
storage: CleanupStorage,
|
|
51
|
+
prefix: string,
|
|
52
|
+
visit: (key: string, value: unknown) => Promise<void>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
let start: string | undefined;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const listed = await storage.list({
|
|
57
|
+
prefix,
|
|
58
|
+
limit: PAGE,
|
|
59
|
+
...(start ? { start } : {}),
|
|
60
|
+
});
|
|
61
|
+
if (listed.size === 0) return;
|
|
62
|
+
let last = "";
|
|
63
|
+
for (const [key, value] of listed) {
|
|
64
|
+
last = key;
|
|
65
|
+
if (start !== undefined && key === start) continue;
|
|
66
|
+
await visit(key, value);
|
|
67
|
+
}
|
|
68
|
+
if (listed.size < PAGE) return;
|
|
69
|
+
start = `${last}\0`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The run with supersede's fields gone, or `undefined` when it had none. */
|
|
74
|
+
function withoutSupersede(
|
|
75
|
+
run: Record<string, unknown>,
|
|
76
|
+
): Record<string, unknown> | undefined {
|
|
77
|
+
const superseded = run.status === "superseded";
|
|
78
|
+
if (!superseded && !RETIRED_RUN_FIELDS.some((field) => field in run)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const cleaned = { ...run };
|
|
82
|
+
for (const field of RETIRED_RUN_FIELDS) delete cleaned[field];
|
|
83
|
+
if (superseded) {
|
|
84
|
+
cleaned.status = "cancelled";
|
|
85
|
+
cleaned.stopRequestedAt =
|
|
86
|
+
run.stopRequestedAt ?? run.supersededAt ?? run.acceptedAt;
|
|
87
|
+
}
|
|
88
|
+
return cleaned;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A projected row or update whose run is `superseded`, rewritten. */
|
|
92
|
+
function projectedWithoutSupersede(value: unknown): unknown {
|
|
93
|
+
const stored = record(value);
|
|
94
|
+
const payload = record(stored?.payload);
|
|
95
|
+
const run = record(payload?.run);
|
|
96
|
+
if (!stored || !payload || !run || run.status !== "superseded") {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const outcome = record(run.outcome);
|
|
100
|
+
return {
|
|
101
|
+
...stored,
|
|
102
|
+
payload: {
|
|
103
|
+
...payload,
|
|
104
|
+
run: {
|
|
105
|
+
...run,
|
|
106
|
+
status: "cancelled",
|
|
107
|
+
stopRequestedAt: run.stopRequestedAt ?? run.admittedAt,
|
|
108
|
+
outcome: { ...outcome, type: "cancelled" },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function carriesRetiredInput(value: unknown): boolean {
|
|
115
|
+
const inputs = record(value)?.inputs;
|
|
116
|
+
return (
|
|
117
|
+
Array.isArray(inputs) &&
|
|
118
|
+
inputs.some((input) => record(input)?.kind === RETIRED_INPUT_KIND)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function cleanSupersedeStateV1(
|
|
123
|
+
storage: CleanupStorage,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
if (await storage.get(RECEIPT)) return;
|
|
126
|
+
let runs = 0;
|
|
127
|
+
await page(storage, RUN_PREFIX, async (key, value) => {
|
|
128
|
+
const run = record(value);
|
|
129
|
+
const cleaned = run && withoutSupersede(run);
|
|
130
|
+
if (!cleaned) return;
|
|
131
|
+
runs += 1;
|
|
132
|
+
await storage.put(key, cleaned);
|
|
133
|
+
});
|
|
134
|
+
const waiting = await storage.get(RETIRED_PENDING_RUN_KEY);
|
|
135
|
+
if (typeof waiting === "string") {
|
|
136
|
+
const run = record(await storage.get(`${RUN_PREFIX}${waiting}`));
|
|
137
|
+
if (run?.status === "running" && typeof run.acceptedAt === "string") {
|
|
138
|
+
await storage.put(pendingUserRunKey(run.acceptedAt, waiting), waiting);
|
|
139
|
+
}
|
|
140
|
+
await storage.delete(RETIRED_PENDING_RUN_KEY);
|
|
141
|
+
}
|
|
142
|
+
for (const prefix of [CONVERSATION_ROW_PREFIX, CONVERSATION_UPDATE_PREFIX]) {
|
|
143
|
+
await page(storage, prefix, async (key, value) => {
|
|
144
|
+
const cleaned = projectedWithoutSupersede(value);
|
|
145
|
+
if (cleaned === undefined) return;
|
|
146
|
+
runs += 1;
|
|
147
|
+
await storage.put(key, cleaned);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
let inputs = 0;
|
|
151
|
+
await page(storage, ROUTINE_WAKE_PREFIX, async (key, value) => {
|
|
152
|
+
if (record(value)?.kind !== RETIRED_INPUT_KIND) return;
|
|
153
|
+
inputs += 1;
|
|
154
|
+
await storage.delete(key);
|
|
155
|
+
});
|
|
156
|
+
await page(storage, ROUTINE_DRAIN_PREFIX, async (key, value) => {
|
|
157
|
+
if (!carriesRetiredInput(value)) return;
|
|
158
|
+
inputs += 1;
|
|
159
|
+
await storage.delete(key);
|
|
160
|
+
});
|
|
161
|
+
await storage.put(RECEIPT, { schemaVersion: 1, runs, inputs });
|
|
162
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ACTIVE_RUN_KEY,
|
|
3
|
+
IDENTITY_KEY,
|
|
4
|
+
LATEST_EVENTS_KEY,
|
|
5
|
+
NOTIFICATION_PREFIX,
|
|
6
|
+
PENDING_AGENT_RUN_PREFIX,
|
|
7
|
+
PENDING_USER_RUN_PREFIX,
|
|
8
|
+
RUN_INDEX_PREFIX,
|
|
9
|
+
RUN_PREFIX,
|
|
10
|
+
SESSION_EVENT_LOG_PREFIX,
|
|
11
|
+
} from "@frockbot/core/durable";
|
|
12
|
+
|
|
13
|
+
/** One-time cleanup explicitly authorized by the owner for this incident.
|
|
14
|
+
* Remove after the receipts have been verified in the release.
|
|
15
|
+
* There is deliberately no route, configuration knob, or caller-supplied scope.
|
|
16
|
+
*/
|
|
17
|
+
const OWNER = "vgpqfaCcwnPlzjYdb2mIfNcOW1YV0SkG";
|
|
18
|
+
const BOTS = new Set([
|
|
19
|
+
"bob-daff7ee3",
|
|
20
|
+
"native-qa-20260905",
|
|
21
|
+
"test-n5jJuqCi",
|
|
22
|
+
// Its retired events also prevent rebuilding the account's search index.
|
|
23
|
+
"test-99860758",
|
|
24
|
+
]);
|
|
25
|
+
const RECEIPT = "maintenance:chat-reset:2026-09-08";
|
|
26
|
+
const PREFIXES = [
|
|
27
|
+
RUN_PREFIX,
|
|
28
|
+
RUN_INDEX_PREFIX,
|
|
29
|
+
SESSION_EVENT_LOG_PREFIX,
|
|
30
|
+
NOTIFICATION_PREFIX,
|
|
31
|
+
"shell:unread",
|
|
32
|
+
"shell:preview",
|
|
33
|
+
"shell:approval:",
|
|
34
|
+
"shell:turn-tool-catalog:",
|
|
35
|
+
"stop-receipt:",
|
|
36
|
+
"bot-announcement:",
|
|
37
|
+
PENDING_USER_RUN_PREFIX,
|
|
38
|
+
];
|
|
39
|
+
const KEYS = [
|
|
40
|
+
ACTIVE_RUN_KEY,
|
|
41
|
+
LATEST_EVENTS_KEY,
|
|
42
|
+
"conversation",
|
|
43
|
+
"conversation-index",
|
|
44
|
+
"bot-announcement-sequence",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export async function cleanIncidentTestChatsV1(
|
|
48
|
+
storage: DurableObjectStorage,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const receipt = await storage.transaction(async (tx) => {
|
|
51
|
+
const identity = await tx.get<{ userId: string; botId: string }>(
|
|
52
|
+
IDENTITY_KEY,
|
|
53
|
+
);
|
|
54
|
+
if (
|
|
55
|
+
identity?.userId !== OWNER ||
|
|
56
|
+
!BOTS.has(identity.botId) ||
|
|
57
|
+
(await tx.get(RECEIPT))
|
|
58
|
+
)
|
|
59
|
+
return;
|
|
60
|
+
for (const prefix of [PENDING_AGENT_RUN_PREFIX, "task-active:"]) {
|
|
61
|
+
if ((await tx.list({ prefix, limit: 1 })).size) return;
|
|
62
|
+
}
|
|
63
|
+
let startAfter: string | undefined;
|
|
64
|
+
for (;;) {
|
|
65
|
+
const page = await tx.list<{ status?: string }>({
|
|
66
|
+
prefix: RUN_PREFIX,
|
|
67
|
+
limit: 64,
|
|
68
|
+
...(startAfter ? { startAfter } : {}),
|
|
69
|
+
});
|
|
70
|
+
if ([...page.values()].some((run) => run.status === "running")) return;
|
|
71
|
+
if (page.size < 64) break;
|
|
72
|
+
startAfter = [...page.keys()].at(-1);
|
|
73
|
+
}
|
|
74
|
+
let deletedKeys = 0;
|
|
75
|
+
for (const prefix of PREFIXES) {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const page = await tx.list({ prefix, limit: 64 });
|
|
78
|
+
if (!page.size) break;
|
|
79
|
+
deletedKeys += await tx.delete([...page.keys()]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
deletedKeys += await tx.delete(KEYS);
|
|
83
|
+
const result = {
|
|
84
|
+
schemaVersion: 1,
|
|
85
|
+
botId: identity.botId,
|
|
86
|
+
deletedKeys,
|
|
87
|
+
completedAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
await tx.put(RECEIPT, result);
|
|
90
|
+
return result;
|
|
91
|
+
});
|
|
92
|
+
if (receipt) console.info({ event: "incident-chat-cleanup", ...receipt });
|
|
93
|
+
}
|