@frockbot/plugin-authoring 0.3.4 → 0.3.6

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/frockbot.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 3,
3
3
  "id": "authoring",
4
- "displayName": "Package Authoring",
4
+ "displayName": "Plugin authoring",
5
5
  "version": "0.0.1",
6
6
  "compatibility": {
7
7
  "frockbot": ">=0.0.1"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-authoring",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsc --noEmit -p tsconfig.json"
22
22
  },
23
23
  "dependencies": {
24
- "@frockbot/kernel-contracts": "0.3.4",
24
+ "@frockbot/kernel-contracts": "0.3.6",
25
25
  "cordis": "4.0.0-rc.8"
26
26
  },
27
27
  "devDependencies": {
package/src/agent.test.ts CHANGED
@@ -172,6 +172,56 @@ describe("the package_author tool", () => {
172
172
  await dispose();
173
173
  });
174
174
 
175
+ test("every refusal and every throw closes the intent with a failure event", async () => {
176
+ // F12: `package/author-intent` was written, the host refused, and the run
177
+ // reached `turn/end` with no `package/authored` and no failure event of
178
+ // any kind — the session log said an effect was intended and never said
179
+ // how it ended (user `packages-4`, Bot `maker-2351d56c`, run `e015c05e`).
180
+ for (const scenario of [
181
+ {
182
+ host: stubHost({
183
+ status: "refused" as const,
184
+ reason: "the Package bundler could not be reached",
185
+ failureId: "authoring-failure-2",
186
+ }),
187
+ expected: {
188
+ effect: "author",
189
+ reason: "the Package bundler could not be reached",
190
+ failureId: "authoring-failure-2",
191
+ },
192
+ },
193
+ {
194
+ // An unmodelled throw escaped `execute` the same way.
195
+ host: {
196
+ ...stubHost({
197
+ status: "refused" as const,
198
+ reason: "unused",
199
+ failureId: "unused",
200
+ }),
201
+ author: () => Promise.reject(new Error("storage is unavailable")),
202
+ },
203
+ expected: { effect: "author", reason: "storage is unavailable" },
204
+ },
205
+ ]) {
206
+ const { session, sessions, dispose } = await openSession();
207
+ const tool = createPackageAuthorTool(scenario.host as never, sessions);
208
+
209
+ const result = await tool.execute(INPUT, CONTEXT);
210
+
211
+ expect(result.isError).toBe(true);
212
+ const types = session.events.map((event) => event.type);
213
+ expect(types).toContain("package/author-intent");
214
+ expect(types).not.toContain("package/authored");
215
+ expect(types.at(-1)).toBe("package/effect-failed");
216
+ expect(session.events.at(-1)).toMatchObject({
217
+ ...scenario.expected,
218
+ turn: 4,
219
+ step: 2,
220
+ });
221
+ await dispose();
222
+ }
223
+ });
224
+
175
225
  test("a refusal leaves the intent recorded and no authored event", async () => {
176
226
  const { session, sessions, dispose } = await openSession();
177
227
  const tool = createPackageAuthorTool(
package/src/agent.ts CHANGED
@@ -98,6 +98,41 @@ function refusalText(
98
98
  ].join(" ");
99
99
  }
100
100
 
101
+ /**
102
+ * Close an open `package/*-intent` with the failure that ended it, then answer
103
+ * the model. The pair is the proof the effect happened at all, so the failure
104
+ * event is flushed before the tool result is returned; a failure to record the
105
+ * failure is not allowed to hide the original one.
106
+ */
107
+ async function closeWithFailure(
108
+ session: Session,
109
+ position: AuthoringTurnPositionV1,
110
+ failure: {
111
+ effectId: string;
112
+ effect: "author" | "undo" | "catalog-change";
113
+ reason: string;
114
+ failureId?: string;
115
+ content: string;
116
+ },
117
+ ): Promise<{ content: string; isError: true }> {
118
+ try {
119
+ session.append({
120
+ type: "package/effect-failed",
121
+ ...position,
122
+ effectId: failure.effectId,
123
+ effect: failure.effect,
124
+ reason: failure.reason,
125
+ ...(failure.failureId === undefined
126
+ ? {}
127
+ : { failureId: failure.failureId }),
128
+ });
129
+ await session.flush();
130
+ } catch {
131
+ // Deliberately swallowed: the model still gets the real reason.
132
+ }
133
+ return { content: failure.content, isError: true };
134
+ }
135
+
101
136
  function authoredText(
102
137
  outcome: Extract<AuthorPackageOutcomeV1, { status: "authored" }>,
103
138
  ): string {
@@ -181,15 +216,34 @@ export function createPackageAuthorTool(
181
216
  });
182
217
  await session.flush();
183
218
 
184
- const outcome = await host.author({
185
- input: decoded,
186
- sourceHash,
187
- effectId,
188
- sessionId: context.sessionId,
189
- position,
190
- });
219
+ let outcome: AuthorPackageOutcomeV1;
220
+ try {
221
+ outcome = await host.author({
222
+ input: decoded,
223
+ sourceHash,
224
+ effectId,
225
+ sessionId: context.sessionId,
226
+ position,
227
+ });
228
+ } catch (error) {
229
+ // An unmodelled throw — a storage error, a quota RPC rejection, a
230
+ // manifest that will not decode — used to escape `execute` and leave
231
+ // the intent with no outcome of any kind (F12).
232
+ return closeWithFailure(session, position, {
233
+ effectId,
234
+ effect: "author",
235
+ reason: errorMessage(error),
236
+ content: `package_author failed: ${errorMessage(error)} Nothing was activated.`,
237
+ });
238
+ }
191
239
  if (outcome.status === "refused") {
192
- return { content: refusalText(outcome), isError: true };
240
+ return closeWithFailure(session, position, {
241
+ effectId,
242
+ effect: "author",
243
+ reason: outcome.reason,
244
+ failureId: outcome.failureId,
245
+ content: refusalText(outcome),
246
+ });
193
247
  }
194
248
  session.append({
195
249
  type: "package/authored",
@@ -255,17 +309,30 @@ export function createPackageUndoTool(
255
309
  : {}),
256
310
  });
257
311
  await session.flush();
258
- const outcome = await host.undo({
259
- input: decoded,
260
- effectId,
261
- sessionId: context.sessionId,
262
- position,
263
- });
312
+ let outcome: PackageUndoOutcomeV1;
313
+ try {
314
+ outcome = await host.undo({
315
+ input: decoded,
316
+ effectId,
317
+ sessionId: context.sessionId,
318
+ position,
319
+ });
320
+ } catch (error) {
321
+ return closeWithFailure(session, position, {
322
+ effectId,
323
+ effect: "undo",
324
+ reason: errorMessage(error),
325
+ content: `package_undo failed: ${errorMessage(error)} No connection action was undone.`,
326
+ });
327
+ }
264
328
  if (outcome.status === "refused") {
265
- return {
329
+ return closeWithFailure(session, position, {
330
+ effectId,
331
+ effect: "undo",
332
+ reason: outcome.reason,
333
+ failureId: outcome.failureId,
266
334
  content: `Package undo was refused: ${outcome.reason} A durable failure record "${outcome.failureId}" was written. No connection action was undone.`,
267
- isError: true,
268
- };
335
+ });
269
336
  }
270
337
  session.append({
271
338
  type: "package/undo-recorded",
package/src/records.ts CHANGED
@@ -190,6 +190,20 @@ export type AuthoringEffectOutcomeV1 =
190
190
  effectId: string;
191
191
  failureId: string;
192
192
  reason: string;
193
+ }
194
+ /**
195
+ * The bundler was never reached — unreachable, or past its deadline before
196
+ * it answered. Nothing ran, so nothing is uncertain: the effect is as good
197
+ * as unstarted and an identical retry may proceed. Without this an outage
198
+ * poisoned that exact `effectId` (run + Package + source hash) for the rest
199
+ * of the run, so retrying the same source could never succeed (F11).
200
+ */
201
+ | {
202
+ schemaVersion: 1;
203
+ status: "unreachable";
204
+ effectId: string;
205
+ failureId: string;
206
+ reason: string;
193
207
  };
194
208
 
195
209
  export type AuthoringEffectClassificationV1 =
@@ -225,13 +239,16 @@ export function classifyAuthoringEffectV1(input: {
225
239
  "an authoring outcome is recorded for an effect with no recorded intent",
226
240
  };
227
241
  }
228
- return input.outcome.status === "bundled"
229
- ? { kind: "settled", outcome: input.outcome }
230
- : {
231
- kind: "failed",
232
- failureId: input.outcome.failureId,
233
- reason: input.outcome.reason,
234
- };
242
+ if (input.outcome.status === "bundled") {
243
+ return { kind: "settled", outcome: input.outcome };
244
+ }
245
+ // Nothing ran, so the effect may run: `fresh`, not `unknown`.
246
+ if (input.outcome.status === "unreachable") return { kind: "fresh" };
247
+ return {
248
+ kind: "failed",
249
+ failureId: input.outcome.failureId,
250
+ reason: input.outcome.reason,
251
+ };
235
252
  }
236
253
  if (!input.intent) return { kind: "fresh" };
237
254
  return {