@frockbot/plugin-shell 0.3.8 → 0.3.10

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/src/backend.ts CHANGED
@@ -214,6 +214,12 @@ import {
214
214
  } from "./backend-routines.js";
215
215
  import { RoutineInboxStore } from "@frockbot/plugin-routines/inbox-store";
216
216
  import {
217
+ ROUTINE_INBOX_LIMIT,
218
+ ROUTINE_INBOX_PREFIX,
219
+ } from "@frockbot/plugin-routines/storage-keys";
220
+ import {
221
+ decodeRoutineInboxEntryV1,
222
+ routineFailureSentenceV1,
217
223
  subagentAttributionV1,
218
224
  ROUTINE_INBOX_TEXT_MAX,
219
225
  ROUTINE_WAKE_TITLE_MAX,
@@ -409,6 +415,12 @@ import {
409
415
  type ClientRunV1,
410
416
  type ClientTurnV1,
411
417
  } from "./run-protocol.js";
418
+ import { notificationIdV1 } from "./notification-id.js";
419
+ import {
420
+ type CompositionManifestSourcesV1,
421
+ compositionMemberManifestDocumentV1,
422
+ compositionMemberManifestV1,
423
+ } from "./composition-manifest.js";
412
424
  import {
413
425
  BOT_DEBUG_DEFAULT_RUN_LIMIT_V1,
414
426
  BOT_DEBUG_EVENT_BYTES_V1,
@@ -1227,15 +1239,44 @@ export class ShellBotBackendContribution {
1227
1239
  return { schemaVersion: 1, skills: entries };
1228
1240
  }
1229
1241
 
1230
- /** The one durable manifest lookup used by mounts, commands, and UI views. */
1231
- private async readCompositionMemberManifest(
1242
+ /**
1243
+ * The one durable manifest lookup used by mounts, commands, and UI views —
1244
+ * as the **stored document**, byte-for-byte what `manifestHash` was taken
1245
+ * over at authoring time.
1246
+ *
1247
+ * Decoding rebuilds the object (`decodeV5` always writes a `configuration`
1248
+ * key, for one), so a decoded manifest does not canonicalize back to the
1249
+ * recorded hash. Every mount re-verifies that hash
1250
+ * (`botIsolatePackageDescriptorV1`), so the raw document is the only thing
1251
+ * that can be handed to it; callers that want the typed shape decode it
1252
+ * themselves through `readCompositionMemberManifest`.
1253
+ */
1254
+ private compositionManifestSources(): CompositionManifestSourcesV1 {
1255
+ return {
1256
+ stored: (manifestHash) =>
1257
+ this.ctx.storage.get<AuthoredManifestRecordV1>(
1258
+ authorshipManifestKey(manifestHash),
1259
+ ),
1260
+ application: (member) => this.readApplicationMemberManifest(member),
1261
+ };
1262
+ }
1263
+
1264
+ private readCompositionMemberManifestDocument(
1265
+ member: CompositionMemberV1,
1266
+ ): Promise<unknown | undefined> {
1267
+ return compositionMemberManifestDocumentV1(
1268
+ member,
1269
+ this.compositionManifestSources(),
1270
+ );
1271
+ }
1272
+
1273
+ private readCompositionMemberManifest(
1232
1274
  member: CompositionMemberV1,
1233
1275
  ): Promise<FrockBotManifest | undefined> {
1234
- const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
1235
- authorshipManifestKey(member.manifestHash),
1276
+ return compositionMemberManifestV1(
1277
+ member,
1278
+ this.compositionManifestSources(),
1236
1279
  );
1237
- if (stored) return decodeFrockBotManifest(stored.manifest);
1238
- return await this.readApplicationMemberManifest(member);
1239
1280
  }
1240
1281
 
1241
1282
  /**
@@ -1264,6 +1305,19 @@ export class ShellBotBackendContribution {
1264
1305
  return declared.manifest;
1265
1306
  }
1266
1307
 
1308
+ /** The stored manifest document a mount hashes, or a modelled failure. */
1309
+ private async requireCompositionMemberManifestDocument(
1310
+ member: CompositionMemberV1,
1311
+ ): Promise<unknown> {
1312
+ const document = await this.readCompositionMemberManifestDocument(member);
1313
+ if (document === undefined) {
1314
+ throw new Error(
1315
+ `package "${member.packageId}" manifest "${member.manifestHash}" is unavailable`,
1316
+ );
1317
+ }
1318
+ return document;
1319
+ }
1320
+
1267
1321
  private async requireCompositionMemberManifest(
1268
1322
  member: CompositionMemberV1,
1269
1323
  ): Promise<FrockBotManifest> {
@@ -1826,7 +1880,11 @@ export class ShellBotBackendContribution {
1826
1880
  fallback: CompositionGenerationV1,
1827
1881
  ): Promise<void> {
1828
1882
  await this.authority.recordNotification({
1829
- notificationId: `composition-failure:${failure.generationId}:${failure.attempt}`,
1883
+ notificationId: notificationIdV1(
1884
+ "composition-failure",
1885
+ failure.generationId,
1886
+ failure.attempt,
1887
+ ),
1830
1888
  runId,
1831
1889
  createdAt: failure.at,
1832
1890
  title: `${settings.profile.name} kept its last working Packages`,
@@ -1877,7 +1935,8 @@ export class ShellBotBackendContribution {
1877
1935
  artifacts,
1878
1936
  this.bundledPackageArtifacts,
1879
1937
  ),
1880
- manifestFor: (member) => this.requireCompositionMemberManifest(member),
1938
+ manifestFor: (member) =>
1939
+ this.requireCompositionMemberManifestDocument(member),
1881
1940
  capabilitiesFor: (member) =>
1882
1941
  mintCapabilities({
1883
1942
  props: {
@@ -2686,7 +2745,12 @@ export class ShellBotBackendContribution {
2686
2745
  );
2687
2746
  if (!connection?.generation) {
2688
2747
  await this.authority.recordNotification({
2689
- notificationId: `package-connection-unavailable:${input.runId}:${input.packageId}:${input.request}`,
2748
+ notificationId: notificationIdV1(
2749
+ "package-connection-unavailable",
2750
+ input.runId,
2751
+ input.packageId,
2752
+ input.request,
2753
+ ),
2690
2754
  runId: input.runId,
2691
2755
  createdAt: new Date().toISOString(),
2692
2756
  title: "Connection unavailable",
@@ -2714,7 +2778,11 @@ export class ShellBotBackendContribution {
2714
2778
  }
2715
2779
  const request = decodeIsolateNotificationRequestV1(input.request);
2716
2780
  await this.authority.recordNotification({
2717
- notificationId: `package:${input.packageId}:${request.notificationId}`,
2781
+ notificationId: notificationIdV1(
2782
+ "package",
2783
+ input.packageId,
2784
+ request.notificationId,
2785
+ ),
2718
2786
  runId: input.runId,
2719
2787
  createdAt: new Date().toISOString(),
2720
2788
  title: request.title,
@@ -3101,14 +3169,14 @@ export class ShellBotBackendContribution {
3101
3169
  await this.authority.recordNotification({
3102
3170
  // The same id shape the completion path uses, so one firing is one
3103
3171
  // intent however many times the alarm retries it.
3104
- notificationId: `routine-failed:${fire.fireId}`,
3172
+ notificationId: notificationIdV1("routine-failed", fire.fireId),
3105
3173
  runId: fire.fireId,
3106
3174
  createdAt: new Date().toISOString(),
3107
3175
  title: `${settings.profile.name} could not run a Routine`,
3108
- body: (outcome.summary ?? "The firing ended without saying why.").slice(
3109
- 0,
3110
- 240,
3111
- ),
3176
+ // The same sentence the inbox entry carries. A notification is the one
3177
+ // surface a person reads without asking for it, so it is the last place
3178
+ // a kernel invariant belongs.
3179
+ body: routineFailureSentenceV1(outcome.summary).slice(0, 240),
3112
3180
  });
3113
3181
  }
3114
3182
  // -------------------------------------------------------------------------
@@ -3348,11 +3416,38 @@ export class ShellBotBackendContribution {
3348
3416
  }
3349
3417
  if (admission.status === "replayed") {
3350
3418
  // The same tool call, reconciled or retried: the task it already
3351
- // dispatched is the answer, never a second child.
3419
+ // dispatched is the answer, never a second child. A foreground call
3420
+ // still waits for it — the caller asked for the result, and returning
3421
+ // "dispatched" the instant a replay is recognised is what made a
3422
+ // `background:false` Task look like it completed with no output.
3423
+ const replayed = admission.record;
3424
+ const settled =
3425
+ replayed.outcome ??
3426
+ (request.background
3427
+ ? undefined
3428
+ : await this.awaitBlockingTask(
3429
+ identity,
3430
+ taskAnchorIdV1(replayed.childSessionId),
3431
+ replayed.taskId,
3432
+ ));
3433
+ if (settled) {
3434
+ return {
3435
+ status: "settled",
3436
+ taskId: replayed.taskId,
3437
+ model: replayed.model.slug,
3438
+ taskStatus: settled.status,
3439
+ ...(settled.summary === undefined
3440
+ ? {}
3441
+ : { summary: settled.summary }),
3442
+ ...(settled.failure === undefined
3443
+ ? {}
3444
+ : { failure: settled.failure }),
3445
+ };
3446
+ }
3352
3447
  return {
3353
3448
  status: "dispatched",
3354
- taskId: admission.record.taskId,
3355
- model: admission.record.model.slug,
3449
+ taskId: replayed.taskId,
3450
+ model: replayed.model.slug,
3356
3451
  };
3357
3452
  }
3358
3453
  const reservation = await this.subagentSlots(identity).reserve({
@@ -3454,20 +3549,27 @@ export class ShellBotBackendContribution {
3454
3549
  * Durable Object already does inside a Turn, and the outbound probe is the
3455
3550
  * same call reconciliation makes.
3456
3551
  */
3457
- private async awaitBlockingTask(
3552
+ protected async awaitBlockingTask(
3458
3553
  identity: BotIdentity,
3459
3554
  anchorTaskId: string,
3460
3555
  taskId: string,
3461
3556
  ): Promise<TaskOutcomeV1 | undefined> {
3462
3557
  const binding = this.subagentBinding;
3463
3558
  const deadline = Date.now() + TASK_BLOCKING_TIMEOUT_MS_V1;
3559
+ // A read that fails once is transient storage contention, not an answer:
3560
+ // abandoning the wait on the first one returned "still running" for a
3561
+ // child that was about to settle, and taught the model to poll. Only a
3562
+ // record that stays unreadable ends the wait early.
3563
+ const readFailureLimit = 3;
3564
+ let readFailures = 0;
3464
3565
  for (;;) {
3465
3566
  try {
3466
3567
  const record = await this.tasks.read(taskId);
3568
+ readFailures = 0;
3467
3569
  if (record.outcome) return record.outcome;
3468
3570
  } catch {
3469
- // A record that cannot be read is not a reason to hold the Turn.
3470
- return undefined;
3571
+ readFailures += 1;
3572
+ if (readFailures >= readFailureLimit) return undefined;
3471
3573
  }
3472
3574
  if (binding) {
3473
3575
  try {
@@ -3620,7 +3722,7 @@ export class ShellBotBackendContribution {
3620
3722
  }
3621
3723
  if (!settings.notifications.enabled) return;
3622
3724
  await this.authority.recordNotification({
3623
- notificationId: `task-settled:${task.taskId}`,
3725
+ notificationId: notificationIdV1("task-settled", task.taskId),
3624
3726
  runId: task.taskId,
3625
3727
  createdAt: at,
3626
3728
  title: `${settings.profile.name} finished a subagent task`,
@@ -5260,11 +5362,30 @@ export class ShellBotBackendContribution {
5260
5362
  const index = await this.authority.listRunIndex({
5261
5363
  limit: UNREAD_COUNT_CAP + 1,
5262
5364
  });
5365
+ // Counted straight off the keys rather than through `RoutineInboxStore`:
5366
+ // its `list()` trims the inbox, and the unread fan-out is a read every
5367
+ // sidebar poll makes for every Bot — it must not write, least of all into
5368
+ // an object that is running a Turn. An undecodable row is skipped, because
5369
+ // a badge is never worth failing a read for.
5370
+ const stored = await this.ctx.storage.list<unknown>({
5371
+ prefix: ROUTINE_INBOX_PREFIX,
5372
+ limit: ROUTINE_INBOX_LIMIT,
5373
+ });
5374
+ let failures = 0;
5375
+ for (const value of stored.values()) {
5376
+ try {
5377
+ const entry = decodeRoutineInboxEntryV1(value);
5378
+ if (entry.failure === true && !entry.acknowledged) failures += 1;
5379
+ } catch {
5380
+ continue;
5381
+ }
5382
+ }
5263
5383
  return projectBotUnreadViewV1(
5264
5384
  identity.botId,
5265
5385
  state,
5266
5386
  index.map((entry) => entry.cursor),
5267
5387
  await this.sidebarPreview(storedPreview, index),
5388
+ failures,
5268
5389
  );
5269
5390
  }
5270
5391
 
@@ -5405,7 +5526,7 @@ export class ShellBotBackendContribution {
5405
5526
  // `automation_completion_inbox` row".
5406
5527
  if (handoff === undefined) return undefined;
5407
5528
  return {
5408
- notificationId: `routine-wake:${result.runId}`,
5529
+ notificationId: notificationIdV1("routine-wake", result.runId),
5409
5530
  runId: result.runId,
5410
5531
  createdAt: new Date().toISOString(),
5411
5532
  title: `${settings.profile.name} finished a Routine`,
@@ -1631,6 +1631,79 @@ describe("active durable Turn projection", () => {
1631
1631
  });
1632
1632
  });
1633
1633
 
1634
+ test("a running Turn's partial text fills the bubble it will settle into", () => {
1635
+ const state: Pick<
1636
+ FrockBotWebData,
1637
+ "messages" | "activeRunId" | "activeRun"
1638
+ > = { messages: [] };
1639
+
1640
+ projectDurableRuns(
1641
+ state,
1642
+ [],
1643
+ [
1644
+ {
1645
+ runId: "run-9",
1646
+ input: "Explain",
1647
+ events: [],
1648
+ status: "running",
1649
+ partialText: "Because it",
1650
+ },
1651
+ ],
1652
+ );
1653
+ expect(state.messages[1]).toMatchObject({
1654
+ text: "Because it",
1655
+ status: "streaming",
1656
+ });
1657
+
1658
+ // One bubble: the settled answer replaces the partial one in place.
1659
+ projectDurableRuns(
1660
+ state,
1661
+ [],
1662
+ [
1663
+ {
1664
+ runId: "run-9",
1665
+ input: "Explain",
1666
+ events: [],
1667
+ status: "completed",
1668
+ responseText: "Because it is.",
1669
+ },
1670
+ ],
1671
+ );
1672
+ expect(state.messages).toHaveLength(2);
1673
+ expect(state.messages[1]).toMatchObject({
1674
+ text: "Because it is.",
1675
+ status: "completed",
1676
+ });
1677
+ });
1678
+
1679
+ test("a Turn that has already delivered a send streams nothing beside it", () => {
1680
+ const state: Pick<
1681
+ FrockBotWebData,
1682
+ "messages" | "activeRunId" | "activeRun"
1683
+ > = { messages: [] };
1684
+
1685
+ projectDurableRuns(
1686
+ state,
1687
+ [],
1688
+ [
1689
+ {
1690
+ runId: "run-10",
1691
+ input: "Explain",
1692
+ events: [
1693
+ {
1694
+ type: "send/to-user",
1695
+ payload: { type: "text", text: "Here you go." },
1696
+ },
1697
+ ],
1698
+ status: "running",
1699
+ partialText: "private scratch space",
1700
+ },
1701
+ ],
1702
+ );
1703
+ expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1704
+ expect(state.messages[1]?.sends).toHaveLength(1);
1705
+ });
1706
+
1634
1707
  test("projects reconciliation-required recovery state", () => {
1635
1708
  const reconciliation: Pick<
1636
1709
  FrockBotWebData,
@@ -283,10 +283,16 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
283
283
  * model's own assistant text is scratch space and the thread does not draw it
284
284
  * (issue 153): drawing both is how a one-word reply arrived twice, once as the
285
285
  * model's text and once as the bubble that was actually delivered.
286
+ *
287
+ * A running Turn has no `responseText` yet — that is written only at
288
+ * settlement — so it draws the words it has written so far. They occupy the
289
+ * same bubble the settled answer will, and the same send gate applies to
290
+ * both: a Turn that has already delivered a bubble streams nothing into a
291
+ * second one.
286
292
  */
287
293
  function visibleAssistantText(run: ClientRun, fallback = ""): string {
288
294
  if (sendsFrom(run.events).length > 0) return "";
289
- return run.responseText ?? fallback;
295
+ return run.responseText ?? run.partialText ?? fallback;
290
296
  }
291
297
 
292
298
  function isTerminalRun(run: ClientRun): boolean {
@@ -1222,6 +1228,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1222
1228
  packageDisplayName: catalogPackage?.displayName,
1223
1229
  connectionDisplayName: connection?.displayName,
1224
1230
  failure: effective.binding?.failure,
1231
+ fallback: Boolean(effective.fallback),
1225
1232
  });
1226
1233
  }
1227
1234
 
@@ -35,6 +35,20 @@ describe("model runtime presentation", () => {
35
35
  );
36
36
  });
37
37
 
38
+ test("names the stand-in model when the chosen one is unavailable", () => {
39
+ // The Bot is answering, so the line is not a failure — it names the model
40
+ // actually in use and says the User's own choice is not it.
41
+ expect(
42
+ modelRuntimeLabel({
43
+ source: "platform",
44
+ modelDisplayName: "Auto",
45
+ providerModelId: "@flock/auto",
46
+ packageDisplayName: "Flock AI",
47
+ fallback: true,
48
+ }),
49
+ ).toBe("Auto · Flock AI · your chosen model is unavailable");
50
+ });
51
+
38
52
  test("shows unavailable and backend failure states", () => {
39
53
  expect(modelRuntimeLabel({ source: "none" })).toBe(
40
54
  "No model available — set one up in Models",
@@ -11,6 +11,11 @@ export function modelRuntimeLabel(input: {
11
11
  packageDisplayName?: string;
12
12
  connectionDisplayName?: string;
13
13
  failure?: string;
14
+ /**
15
+ * The chosen model could not bind — its provider Package is off, or its
16
+ * Connection is gone — and the platform default is answering in its place.
17
+ */
18
+ fallback?: boolean;
14
19
  }): string {
15
20
  if (input.failure) return input.failure;
16
21
  if (input.source === "none" || !input.providerModelId) {
@@ -22,5 +27,6 @@ export function modelRuntimeLabel(input: {
22
27
  const runtime = provider ? `${model} · ${provider}` : model;
23
28
  if (input.source === "bot") return `${runtime} · this Bot only`;
24
29
  if (input.source === "account") return `${runtime} · Account model`;
30
+ if (input.fallback) return `${runtime} · your chosen model is unavailable`;
25
31
  return runtime;
26
32
  }
@@ -0,0 +1,126 @@
1
+ // Every Bot-authored Package failed to mount with `package "aud-usd" stored
2
+ // manifest failed hash verification`, deterministically, on byte-identical
3
+ // source. The mount hashed a *decoded* manifest against a hash taken over the
4
+ // *raw* one, so journey 4 step 2 — "use the tool you just built" — was
5
+ // impossible for anybody. First-party members mounted fine, because their
6
+ // reader already returned the raw document.
7
+ import { describe, expect, test } from "bun:test";
8
+ import { authoredManifestV1 } from "@frockbot/plugin-authoring/shared";
9
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
10
+ import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
11
+ import { botIsolatePackageDescriptorV1 } from "@frockbot/kernel-composition/isolate";
12
+ import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
13
+
14
+ import {
15
+ type CompositionManifestSourcesV1,
16
+ compositionMemberManifestDocumentV1,
17
+ compositionMemberManifestV1,
18
+ } from "./composition-manifest.js";
19
+
20
+ /** What `package_author` writes, exactly as `backend-authoring.ts` builds it. */
21
+ function authoredManifest(): unknown {
22
+ return authoredManifestV1({
23
+ packageId: "aud-usd",
24
+ displayName: "AUD/USD",
25
+ version: "0.0.1",
26
+ tools: [
27
+ {
28
+ name: "aud_usd_rate",
29
+ description: "The current AUD/USD rate.",
30
+ inputSchema: { type: "object", properties: {} },
31
+ },
32
+ ],
33
+ });
34
+ }
35
+
36
+ /** The durable store, as `authorship:manifest:<hash>` holds it. */
37
+ async function storedMember(): Promise<{
38
+ member: CompositionMemberV1;
39
+ manifest: unknown;
40
+ sources: CompositionManifestSourcesV1;
41
+ }> {
42
+ const manifest = authoredManifest();
43
+ // The one hash that matters: taken over the raw document at authoring time.
44
+ const manifestHash = await sha256(canonicalJson(manifest));
45
+ const member: CompositionMemberV1 = {
46
+ packageId: "aud-usd",
47
+ specifier: "bot:aud-usd",
48
+ version: "0.0.1",
49
+ manifestHash,
50
+ provenance: {
51
+ kind: "bot",
52
+ packageId: "aud-usd",
53
+ version: "0.0.1",
54
+ botId: "toolsmith",
55
+ sessionId: "user-1:toolsmith",
56
+ turnId: "turn-1",
57
+ runId: "run-1",
58
+ authoredAt: "2026-09-03T23:49:00.416Z",
59
+ },
60
+ artifact: {
61
+ contentHash: "b".repeat(64),
62
+ size: 128,
63
+ mediaType: "application/javascript",
64
+ bundlerVersion: "worker-bundler@0.2.3",
65
+ },
66
+ };
67
+ const sources: CompositionManifestSourcesV1 = {
68
+ stored: (hash) =>
69
+ Promise.resolve(hash === manifestHash ? { manifest } : undefined),
70
+ application: () => Promise.resolve(undefined),
71
+ };
72
+ return { member, manifest, sources };
73
+ }
74
+
75
+ describe("the manifest a mount is handed", () => {
76
+ test("hashes to what the Composition member recorded", async () => {
77
+ const { member, sources } = await storedMember();
78
+
79
+ const document = await compositionMemberManifestDocumentV1(member, sources);
80
+ const descriptor = await botIsolatePackageDescriptorV1(member, document);
81
+
82
+ expect(descriptor.manifest.id).toBe("aud-usd");
83
+ expect(descriptor.manifest.version).toBe("0.0.1");
84
+ });
85
+
86
+ test("is the stored document, not a rebuild of it", async () => {
87
+ const { member, manifest, sources } = await storedMember();
88
+
89
+ const document = await compositionMemberManifestDocumentV1(member, sources);
90
+
91
+ expect(document).toBe(manifest);
92
+ });
93
+
94
+ test("decoding first is what broke it, and still would", async () => {
95
+ // The guard this test exists for. `decodeFrockBotManifest` rebuilds the
96
+ // object — `decodeV5` always writes a `configuration` key — so the decoded
97
+ // manifest does not canonicalize back to the recorded hash. If the reader
98
+ // ever decodes again, the test above fails and this one says why.
99
+ const { member, manifest } = await storedMember();
100
+ const decoded = decodeFrockBotManifest(manifest);
101
+
102
+ expect(canonicalJson(decoded)).not.toBe(canonicalJson(manifest));
103
+ await expect(
104
+ botIsolatePackageDescriptorV1(member, decoded),
105
+ ).rejects.toThrow("stored manifest failed hash verification");
106
+ });
107
+
108
+ test("the typed reader still answers the shape its callers want", async () => {
109
+ const { member, sources } = await storedMember();
110
+
111
+ const typed = await compositionMemberManifestV1(member, sources);
112
+
113
+ expect(typed?.id).toBe("aud-usd");
114
+ expect(typed?.version).toBe("0.0.1");
115
+ });
116
+
117
+ test("a member with no stored manifest falls back to the application's", async () => {
118
+ const { member, manifest } = await storedMember();
119
+ const document = await compositionMemberManifestDocumentV1(member, {
120
+ stored: () => Promise.resolve(undefined),
121
+ application: () => Promise.resolve(manifest),
122
+ });
123
+
124
+ expect(document).toBe(manifest);
125
+ });
126
+ });
@@ -0,0 +1,55 @@
1
+ import {
2
+ type FrockBotManifest,
3
+ decodeFrockBotManifest,
4
+ } from "@frockbot/kernel-composition";
5
+ import type { CompositionMemberV1 } from "@frockbot/kernel-composition/generation";
6
+
7
+ /**
8
+ * Where a Composition member's manifest can be found. `stored` is
9
+ * `authorship:manifest:<hash>` — written by the authoring path and by a
10
+ * Catalog install, so it covers every member a Bot or its User put into the
11
+ * Composition. `application` is the compiled-in manifest of a first-party
12
+ * artifact-backed member (ADR 0022 decision 8), which came from neither and is
13
+ * already in this bundle. Two *places*, never two answers: the `manifestHash`
14
+ * decides in both.
15
+ */
16
+ export interface CompositionManifestSourcesV1 {
17
+ stored(manifestHash: string): Promise<{ manifest: unknown } | undefined>;
18
+ application(member: CompositionMemberV1): Promise<unknown | undefined>;
19
+ }
20
+
21
+ /**
22
+ * A member's manifest **as the stored document** — byte-for-byte what its
23
+ * `manifestHash` was taken over.
24
+ *
25
+ * This is the seam that broke every Bot-authored Package. Authoring hashes
26
+ * `canonicalJson(rawManifest)` and stores `rawManifest` verbatim; the mount
27
+ * re-hashes whatever it is handed and refuses a mismatch
28
+ * (`botIsolatePackageDescriptorV1`). Returning a *decoded* manifest here put a
29
+ * rebuilt object on the mount's side of that comparison — `decodeV5` always
30
+ * writes a `configuration` key, among other things — so the two hashes could
31
+ * never agree, and every authored Package failed with `stored manifest failed
32
+ * hash verification` while first-party members (which were already read raw)
33
+ * mounted fine.
34
+ *
35
+ * Callers that want the typed shape decode it themselves, downstream of the
36
+ * hash check.
37
+ */
38
+ export async function compositionMemberManifestDocumentV1(
39
+ member: CompositionMemberV1,
40
+ sources: CompositionManifestSourcesV1,
41
+ ): Promise<unknown | undefined> {
42
+ const stored = await sources.stored(member.manifestHash);
43
+ if (stored) return stored.manifest;
44
+ return await sources.application(member);
45
+ }
46
+
47
+ /** The same manifest as the typed shape, for readers that are not mounts. */
48
+ export async function compositionMemberManifestV1(
49
+ member: CompositionMemberV1,
50
+ sources: CompositionManifestSourcesV1,
51
+ ): Promise<FrockBotManifest | undefined> {
52
+ const document = await compositionMemberManifestDocumentV1(member, sources);
53
+ if (document === undefined) return undefined;
54
+ return decodeFrockBotManifest(document);
55
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { notificationIdV1 } from "./notification-id.js";
4
+ import { decodeClientNotificationAcknowledgementCommandV1 } from "./run-protocol.js";
5
+
6
+ /** What the acknowledge endpoint does with an id a notification carried. */
7
+ function acknowledge(notificationId: string): string {
8
+ return decodeClientNotificationAcknowledgementCommandV1({
9
+ schemaVersion: 1,
10
+ action: "acknowledge",
11
+ notificationId,
12
+ }).notificationId;
13
+ }
14
+
15
+ describe("notificationIdV1", () => {
16
+ test("a Composition failure id round-trips through acknowledgement", () => {
17
+ // The shape that 400'd forever: a generation id is `<ISO instant>:<hash>`,
18
+ // and the acknowledge decoder admits no colons.
19
+ const id = notificationIdV1(
20
+ "composition-failure",
21
+ "2026-09-03T23:49:00.416Z:dc03a32d9b717619",
22
+ 1,
23
+ );
24
+ expect(id).not.toContain(":");
25
+ expect(acknowledge(id)).toBe(id);
26
+ });
27
+
28
+ test("every minted id shape acknowledges", () => {
29
+ const minted = [
30
+ notificationIdV1(
31
+ "package-connection-unavailable",
32
+ crypto.randomUUID(),
33
+ "aud-usd",
34
+ "gmail",
35
+ ),
36
+ notificationIdV1("package", "aud-usd", "rate:moved"),
37
+ notificationIdV1("routine-failed", "2026-09-03T23:49:00.416Z:fire"),
38
+ notificationIdV1("task-settled", crypto.randomUUID()),
39
+ notificationIdV1("routine-wake", crypto.randomUUID()),
40
+ ];
41
+ for (const id of minted) expect(acknowledge(id)).toBe(id);
42
+ });
43
+
44
+ test("the same parts mint the same id, so a retry is one intent", () => {
45
+ const parts = [
46
+ "composition-failure",
47
+ "2026-09-03T23:49:00.416Z:abc",
48
+ 2,
49
+ ] as const;
50
+ expect(notificationIdV1(...parts)).toBe(notificationIdV1(...parts));
51
+ });
52
+
53
+ test("distinct parts stay distinct, even past the length ceiling", () => {
54
+ const long = "x".repeat(400);
55
+ const a = notificationIdV1("package", long, "one");
56
+ const b = notificationIdV1("package", long, "two");
57
+ expect(a).not.toBe(b);
58
+ expect(a.length).toBeLessThanOrEqual(128);
59
+ expect(acknowledge(a)).toBe(a);
60
+ expect(acknowledge(b)).toBe(b);
61
+ });
62
+
63
+ test("parts that sanitize to nothing still mint an acknowledgeable id", () => {
64
+ const id = notificationIdV1(":::", "::");
65
+ expect(acknowledge(id)).toBe(id);
66
+ });
67
+ });