@butlerbot/sdk 0.0.33 → 0.0.35

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.
@@ -3,6 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.LinkConversationTransport = void 0;
4
4
  const protocol_1 = require("../link/protocol");
5
5
  const transport_1 = require("./transport");
6
+ /**
7
+ * How long to keep trying to pick a lost turn back up before giving up on it.
8
+ *
9
+ * A turn belongs to its conversation, not to the socket that asked for one, so neither losing
10
+ * the connection nor losing the instance answering it — which is what a deploy does — ends it.
11
+ * Long enough to outlast a deploy of either service, short enough that a caller awaiting a
12
+ * reply is not left there forever when the turn really is gone.
13
+ */
14
+ const RESUME_WINDOW_MS = 60000;
15
+ /** Waits between attempts to pick a turn back up. The last value repeats. */
16
+ const RESUME_BACKOFF_MS = [250, 500, 1000, 2000, 4000, 5000];
6
17
  /**
7
18
  * Carries a turn over an existing Link connection.
8
19
  *
@@ -18,20 +29,20 @@ class LinkConversationTransport {
18
29
  link.on("disconnect", () => { this.sessionId = undefined; });
19
30
  }
20
31
  send(request, handlers) {
21
- let closed = false;
32
+ const listening = { closed: false };
22
33
  const deliver = {
23
- payload: (payload) => { if (!closed)
34
+ payload: (payload) => { if (!listening.closed)
24
35
  handlers.payload(payload); },
25
- convoId: (convoId) => { if (!closed)
36
+ convoId: (convoId) => { if (!listening.closed)
26
37
  handlers.convoId(convoId); },
27
38
  };
28
- void this.runTurn(request, deliver, true).catch((error) => {
39
+ void this.runTurn(request, deliver, true, listening).catch((error) => {
29
40
  const failure = error instanceof protocol_1.LinkError
30
41
  ? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
31
42
  : (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId);
32
43
  deliver.payload(failure);
33
44
  });
34
- return { close: () => { closed = true; } };
45
+ return { close: () => { listening.closed = true; } };
35
46
  }
36
47
  /**
37
48
  * Stops a running turn.
@@ -96,57 +107,46 @@ class LinkConversationTransport {
96
107
  * ordinary answer for one that is simply idle.
97
108
  */
98
109
  attach(request, handlers) {
99
- let closed = false;
100
- const deliver = (payload) => { if (!closed)
110
+ const listening = { closed: false };
111
+ const deliver = (payload) => { if (!listening.closed)
101
112
  handlers.payload(payload); };
102
- const watching = this.link.exchange("conversation.attach", {
103
- chatId: request.chatId,
104
- ...(request.afterEventId ? { afterEventId: request.afterEventId } : {}),
105
- }, {
106
- // A turn takes as long as it takes; only the transport dying ends it early.
107
- timeoutMs: 0,
108
- isDone: (frame) => frame.type === "conversation.done",
109
- onFrame: (frame) => {
110
- if (frame.type === "conversation.event") {
111
- const payload = frame.payload;
112
- const event = payload.event;
113
- const final = event.type === "response_status" && Boolean(event.payload?.completed);
114
- deliver({
115
- success: true,
116
- data: {
117
- response: event,
118
- convoId: payload.chatId ?? request.chatId,
119
- ...(final ? { quitStream: true } : {}),
120
- },
121
- });
122
- return;
123
- }
124
- if (frame.type === "conversation.notice") {
125
- deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? request.chatId));
126
- }
127
- },
128
- });
129
- void watching.then((done) => {
130
- const payload = done.payload;
113
+ const progress = { chatId: request.chatId, lastEventId: request.afterEventId, sawCompletion: false };
114
+ // Watching is allowed to end in silence, which is why `quietWhenGone` is true here and
115
+ // false for a turn of our own: nobody is waiting on an answer to a question they asked.
116
+ const resume = () => this.resume(request.chatId, progress, deliver, listening, { quietWhenGone: true });
117
+ void this.watch(request.chatId, progress, deliver).then(async (payload) => {
131
118
  if (payload.ok)
132
119
  return;
133
120
  // Nothing running is not a failure: the caller asked to watch a conversation
134
121
  // that has nothing to watch, and the stream simply ends.
135
122
  if (payload.code === "no_active_turn")
136
123
  return;
124
+ // The turn is alive, somewhere this connection can no longer see. Following it is
125
+ // the entire point of being here.
126
+ if (payload.code === "turn_suspended") {
127
+ if (payload.lastEventId)
128
+ progress.lastEventId = payload.lastEventId;
129
+ await resume();
130
+ return;
131
+ }
137
132
  deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be watched.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? request.chatId));
138
- }, (error) => {
133
+ }, async (error) => {
139
134
  if (error instanceof protocol_1.LinkError && error.code === "no_active_turn")
140
135
  return;
136
+ // The socket went while we were watching. The turn did not go with it.
137
+ if (error instanceof protocol_1.LinkError && error.code === "disconnected") {
138
+ await resume();
139
+ return;
140
+ }
141
141
  deliver(error instanceof protocol_1.LinkError
142
142
  ? (0, transport_1.failurePayload)(error.code, error.message, error.message, request.chatId)
143
143
  : (0, transport_1.failurePayload)("link_error", String(error), "I'm afraid the connection to Alfred failed.", request.chatId));
144
144
  });
145
145
  return {
146
146
  close: () => {
147
- if (closed)
147
+ if (listening.closed)
148
148
  return;
149
- closed = true;
149
+ listening.closed = true;
150
150
  // Best-effort: a socket that has gone has already ended the watch for us.
151
151
  try {
152
152
  this.link.send("conversation.detach", { chatId: request.chatId });
@@ -157,15 +157,14 @@ class LinkConversationTransport {
157
157
  },
158
158
  };
159
159
  }
160
- async runTurn(request, handlers, mayRetry) {
160
+ async runTurn(request, handlers, mayRetry, listening) {
161
161
  const sessionId = await this.session(request);
162
- let chatId = request.chatId ?? this.sessionChatId;
162
+ const progress = { chatId: request.chatId ?? this.sessionChatId, sawCompletion: false };
163
163
  let announcedChatId = false;
164
- let sawCompletion = false;
165
164
  const learnChatId = (candidate) => {
166
165
  if (!candidate)
167
166
  return;
168
- chatId = candidate;
167
+ progress.chatId = candidate;
169
168
  this.sessionChatId = candidate;
170
169
  handlers.convoId(candidate);
171
170
  // Mirrors the HTTP transport's first byte, which tells a client which
@@ -176,13 +175,99 @@ class LinkConversationTransport {
176
175
  handlers.payload((0, transport_1.convoStartedPayload)(candidate));
177
176
  }
178
177
  };
179
- learnChatId(chatId);
180
- const done = await this.link.exchange("conversation.chat", {
181
- sessionId,
182
- message: request.message,
183
- ...(request.model ? { model: request.model } : {}),
184
- ...(request.instructions ? { instructions: request.instructions } : {}),
185
- ...(request.personality ? { personality: request.personality } : {}),
178
+ learnChatId(progress.chatId);
179
+ let done;
180
+ try {
181
+ done = await this.link.exchange("conversation.chat", {
182
+ sessionId,
183
+ message: request.message,
184
+ ...(request.model ? { model: request.model } : {}),
185
+ ...(request.instructions ? { instructions: request.instructions } : {}),
186
+ ...(request.personality ? { personality: request.personality } : {}),
187
+ }, {
188
+ // A turn takes as long as it takes; only the transport dying ends it early.
189
+ timeoutMs: 0,
190
+ isDone: (frame) => frame.type === "conversation.done",
191
+ onFrame: (frame) => {
192
+ if (frame.type === "conversation.event") {
193
+ const payload = frame.payload;
194
+ learnChatId(payload.chatId);
195
+ if (payload.eventId)
196
+ progress.lastEventId = payload.eventId;
197
+ const event = payload.event;
198
+ const final = event.type === "response_status" && Boolean(event.payload?.completed);
199
+ if (final)
200
+ progress.sawCompletion = true;
201
+ handlers.payload({
202
+ success: true,
203
+ data: {
204
+ response: event,
205
+ ...(payload.chatId ?? progress.chatId ? { convoId: payload.chatId ?? progress.chatId } : {}),
206
+ ...(final ? { quitStream: true } : {}),
207
+ },
208
+ });
209
+ return;
210
+ }
211
+ if (frame.type === "conversation.notice") {
212
+ const payload = frame.payload;
213
+ learnChatId(payload.chatId);
214
+ handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? progress.chatId));
215
+ }
216
+ },
217
+ });
218
+ }
219
+ catch (error) {
220
+ // The socket went while the turn was running. The turn did not go with it: it
221
+ // belongs to the conversation, and the conversation outlives this connection.
222
+ if (progress.chatId && error instanceof protocol_1.LinkError && error.code === "disconnected") {
223
+ await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
224
+ return;
225
+ }
226
+ throw error;
227
+ }
228
+ const payload = done.payload;
229
+ learnChatId(payload.chatId);
230
+ if (payload.ok) {
231
+ // Nearly always the pipeline's own completion event has already closed the
232
+ // stream; this is for the turn that ended without one.
233
+ if (!progress.sawCompletion)
234
+ handlers.payload((0, transport_1.completedPayload)(payload.chatId ?? progress.chatId));
235
+ return;
236
+ }
237
+ // The session died with a connection we have since replaced. Reopening it is
238
+ // invisible to the caller, and the message has not been delivered yet.
239
+ if (payload.code === "unknown_session" && mayRetry) {
240
+ this.sessionId = undefined;
241
+ await this.runTurn(request, handlers, false, listening);
242
+ return;
243
+ }
244
+ // Core suspended the turn at a deploy and handed it to another instance, and the link
245
+ // service followed it as far as it could. The answer is still being written; rejoining
246
+ // it is the difference between a deploy costing a reply and costing nothing.
247
+ if (payload.code === "turn_suspended" && progress.chatId) {
248
+ if (payload.lastEventId)
249
+ progress.lastEventId = payload.lastEventId;
250
+ await this.resume(progress.chatId, progress, handlers.payload, listening, { quietWhenGone: false });
251
+ return;
252
+ }
253
+ handlers.payload((0, transport_1.failurePayload)(
254
+ // `turn_failed` means the dialogue itself failed, and then `error` holds the
255
+ // code the HTTP transport would have reported.
256
+ payload.code === "turn_failed" ? payload.error ?? payload.code : payload.code ?? "link_error", payload.error ?? "The turn failed.", payload.message ?? payload.error ?? "I'm afraid that turn could not be completed.", payload.chatId ?? progress.chatId));
257
+ }
258
+ /**
259
+ * One `conversation.attach`: streams a turn's events to the caller and resolves with how
260
+ * that watch ended.
261
+ *
262
+ * Shared by watching somebody else's turn and by rejoining one of our own, because from
263
+ * here the two are the same act. `progress` is carried rather than returned: a watch that
264
+ * dies halfway still has to leave behind where it got to, or a resume would replay from
265
+ * the beginning and the caller would read the answer twice.
266
+ */
267
+ async watch(chatId, progress, deliver) {
268
+ const done = await this.link.exchange("conversation.attach", {
269
+ chatId,
270
+ ...(progress.lastEventId ? { afterEventId: progress.lastEventId } : {}),
186
271
  }, {
187
272
  // A turn takes as long as it takes; only the transport dying ends it early.
188
273
  timeoutMs: 0,
@@ -190,48 +275,112 @@ class LinkConversationTransport {
190
275
  onFrame: (frame) => {
191
276
  if (frame.type === "conversation.event") {
192
277
  const payload = frame.payload;
193
- learnChatId(payload.chatId);
278
+ if (payload.eventId)
279
+ progress.lastEventId = payload.eventId;
194
280
  const event = payload.event;
195
281
  const final = event.type === "response_status" && Boolean(event.payload?.completed);
196
282
  if (final)
197
- sawCompletion = true;
198
- handlers.payload({
283
+ progress.sawCompletion = true;
284
+ deliver({
199
285
  success: true,
200
286
  data: {
201
287
  response: event,
202
- ...(payload.chatId ?? chatId ? { convoId: payload.chatId ?? chatId } : {}),
288
+ convoId: payload.chatId ?? chatId,
203
289
  ...(final ? { quitStream: true } : {}),
204
290
  },
205
291
  });
206
292
  return;
207
293
  }
208
294
  if (frame.type === "conversation.notice") {
209
- const payload = frame.payload;
210
- learnChatId(payload.chatId);
211
- handlers.payload((0, transport_1.noticePayload)(payload.message, payload.chatId ?? chatId));
295
+ deliver((0, transport_1.noticePayload)(frame.payload.message, frame.payload.chatId ?? chatId));
212
296
  }
213
297
  },
214
298
  });
215
- const payload = done.payload;
216
- learnChatId(payload.chatId);
217
- if (payload.ok) {
218
- // Nearly always the pipeline's own completion event has already closed the
219
- // stream; this is for the turn that ended without one.
220
- if (!sawCompletion)
221
- handlers.payload((0, transport_1.completedPayload)(payload.chatId ?? chatId));
299
+ return done.payload;
300
+ }
301
+ /**
302
+ * Picks a turn back up after losing sight of it.
303
+ *
304
+ * Two ways to lose one, one way to get it back. The socket can go — a deploy of the link
305
+ * service, a proxy timing out — which rejects the exchange carrying the turn. Or core can
306
+ * suspend the turn at its own deploy and hand it to another instance, which the link
307
+ * service follows for two minutes before giving up and saying `turn_suspended`. Either way
308
+ * the turn is still being answered and the conversation still holds it, so this re-attaches
309
+ * from the last event the caller was actually given and the stream reads as one answer.
310
+ *
311
+ * `no_active_turn` is the ambiguous reply and it is deliberately not treated as an ending:
312
+ * during a handover it means "not picked up yet" far more often than it means "gone", and
313
+ * the window is what decides between them.
314
+ *
315
+ * Nothing here throws. It runs behind a stream the caller already holds, so the outcomes
316
+ * that matter are the ones delivered into it: a completion, or a failure that says plainly
317
+ * that the answer was lost track of rather than that it failed.
318
+ */
319
+ async resume(chatId, progress, deliver, listening, options) {
320
+ const deadline = Date.now() + RESUME_WINDOW_MS;
321
+ for (let attempt = 0; !listening.closed && Date.now() < deadline; attempt++) {
322
+ if (attempt > 0)
323
+ await pause(RESUME_BACKOFF_MS[Math.min(attempt - 1, RESUME_BACKOFF_MS.length - 1)]);
324
+ if (listening.closed)
325
+ return;
326
+ try {
327
+ // The link reconnects on its own; this waits for it rather than racing it.
328
+ await this.link.ready();
329
+ }
330
+ catch (error) {
331
+ // Closed for good means nobody is coming back. Anything else is the link still
332
+ // being down, which is exactly what the window is for.
333
+ if (error instanceof protocol_1.LinkError && error.code === "closed")
334
+ break;
335
+ continue;
336
+ }
337
+ // An attachment from before may still be registered against a connection that is
338
+ // itself still alive, and the server allows only one per conversation. Dropping it
339
+ // first costs nothing when there is none to drop.
340
+ try {
341
+ this.link.send("conversation.detach", { chatId });
342
+ }
343
+ catch {
344
+ // Not connected. The server dropped the attachment with the socket.
345
+ }
346
+ let payload;
347
+ try {
348
+ payload = await this.watch(chatId, progress, deliver);
349
+ }
350
+ catch (error) {
351
+ if (error instanceof protocol_1.LinkError && error.code === "closed")
352
+ break;
353
+ if (error instanceof protocol_1.LinkError && error.code === "no_active_turn" && options.quietWhenGone)
354
+ return;
355
+ continue;
356
+ }
357
+ if (payload.ok) {
358
+ if (!progress.sawCompletion)
359
+ deliver((0, transport_1.completedPayload)(payload.chatId ?? chatId));
360
+ return;
361
+ }
362
+ // Not picked up yet, or suspended again mid-hop. Both mean it is still moving.
363
+ if (payload.code === "no_active_turn") {
364
+ if (options.quietWhenGone)
365
+ return;
366
+ continue;
367
+ }
368
+ if (payload.code === "turn_suspended") {
369
+ if (payload.lastEventId)
370
+ progress.lastEventId = payload.lastEventId;
371
+ continue;
372
+ }
373
+ deliver((0, transport_1.failurePayload)(payload.code ?? "link_error", payload.error ?? "The turn could not be picked back up.", payload.message ?? payload.error ?? "I'm afraid I couldn't follow that response.", payload.chatId ?? chatId));
222
374
  return;
223
375
  }
224
- // The session died with a connection we have since replaced. Reopening it is
225
- // invisible to the caller, and the message has not been delivered yet.
226
- if (payload.code === "unknown_session" && mayRetry) {
227
- this.sessionId = undefined;
228
- await this.runTurn(request, handlers, false);
376
+ if (listening.closed)
229
377
  return;
230
- }
231
- handlers.payload((0, transport_1.failurePayload)(
232
- // `turn_failed` means the dialogue itself failed, and then `error` holds the
233
- // code the HTTP transport would have reported.
234
- payload.code === "turn_failed" ? payload.error ?? payload.code : payload.code ?? "link_error", payload.error ?? "The turn failed.", payload.message ?? payload.error ?? "I'm afraid that turn could not be completed.", payload.chatId ?? chatId));
378
+ // The completion already reached the caller; there is nothing left to say.
379
+ if (progress.sawCompletion)
380
+ return;
381
+ if (options.quietWhenGone)
382
+ return;
383
+ deliver((0, transport_1.failurePayload)("turn_suspended", "The turn could not be picked back up.", "I'm afraid I lost track of that answer. It may well have finished — reopen the conversation to see where it got to.", chatId));
235
384
  }
236
385
  /** Opens a session, or reuses the open one when it is for the same conversation. */
237
386
  async session(request) {
@@ -257,3 +406,10 @@ class LinkConversationTransport {
257
406
  }
258
407
  }
259
408
  exports.LinkConversationTransport = LinkConversationTransport;
409
+ /** Waits, without keeping a Node process alive on its own. */
410
+ function pause(ms) {
411
+ return new Promise(resolve => {
412
+ const timer = setTimeout(resolve, ms);
413
+ timer.unref?.();
414
+ });
415
+ }
@@ -0,0 +1 @@
1
+ export * from "./job";
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./job"), exports);
@@ -0,0 +1,179 @@
1
+ import type { Delivery } from "../outreach/delivery";
2
+ /**
3
+ * A job: long-running work Alfred does on its own, run as a sequence of short shifts.
4
+ *
5
+ * These are the shapes the jobs routes answer with. The server flattens a job row into a
6
+ * `JobView` before it leaves, and this is that view rather than the row.
7
+ */
8
+ export declare const JOB_STATUSES: readonly ["queued", "running", "blocked", "waiting_user", "waiting_approval", "waiting_child", "waiting_budget", "review", "done", "failed", "cancelled"];
9
+ export type JobStatus = typeof JOB_STATUSES[number];
10
+ /** The statuses a job never leaves. */
11
+ export declare const TERMINAL_JOB_STATUSES: readonly JobStatus[];
12
+ export declare const JOB_AUTONOMY_MODES: readonly ["ask", "free", "auto"];
13
+ /** How far a job may act outward on its own: ask first, act freely, or act and report. */
14
+ export type JobAutonomy = typeof JOB_AUTONOMY_MODES[number];
15
+ /** One job as a listing or a detail read shows it. */
16
+ export type JobView = {
17
+ jobId: string;
18
+ /** A short name for listings, derived from the goal when nobody gave one. */
19
+ title: string;
20
+ /** The user's words, verbatim: what the job was asked to do. */
21
+ goal: string;
22
+ status: JobStatus;
23
+ /** Why it is where it is, specific enough to act on. */
24
+ statusDetail: string | null;
25
+ /** A one-line note the running shift may leave. */
26
+ progress: string | null;
27
+ /** The phase being worked, or the last one that was. */
28
+ currentPhaseId: string | null;
29
+ shiftsRun: number;
30
+ /** How many times the job has been reviewed. */
31
+ reviewRounds: number;
32
+ /** What the job has spent in total, in USD. */
33
+ spentUsd: number;
34
+ /** What the job has spent today, in USD. */
35
+ spendTodayUsd: number;
36
+ /** How many questions of this job's are waiting on the user. */
37
+ openQuestions: number;
38
+ lastRunAt: number;
39
+ /** The earliest the runner may pick it up again. */
40
+ wakeAt: number;
41
+ created: number;
42
+ /** `card://…/plan.md`, once a planning shift has written it. */
43
+ planCardUri: string | null;
44
+ /** `card://…/journal.md`, appended to as the job runs. */
45
+ journalCardUri: string | null;
46
+ /** The chat the job was asked for in, and where it reports back. */
47
+ originConversationId: string | null;
48
+ /** What was chosen for this job, or null when nothing was. */
49
+ autonomy: JobAutonomy | null;
50
+ /** When `autonomy` lapses back to asking, in UTC milliseconds. */
51
+ autonomyUntil: number | null;
52
+ /** Things this job asks about however free it otherwise is. */
53
+ alwaysAsk: string[];
54
+ /** What the gate actually reads, with the owner's setting and any lapse applied. */
55
+ effectiveAutonomy: JobAutonomy;
56
+ };
57
+ /** What the owner's jobs may spend today, and where that figure comes from. */
58
+ export type JobAllowanceGranted = {
59
+ perDayUsd: number;
60
+ /** What set the figure: the owner's own allowance, or their tier. */
61
+ source: string;
62
+ remainingUsd: number;
63
+ };
64
+ /** Why the owner's jobs may not run at all. */
65
+ export type JobAllowanceRefused = {
66
+ perDayUsd: 0;
67
+ source: null;
68
+ /** The reason in a word, such as a tier that does not include jobs. */
69
+ refused: string;
70
+ /** The same thing in a sentence, for showing a user. */
71
+ message: string;
72
+ };
73
+ export type JobAllowance = JobAllowanceGranted | JobAllowanceRefused;
74
+ /** Whether an allowance is a refusal rather than a grant. */
75
+ export declare function isJobAllowanceRefused(allowance: JobAllowance): allowance is JobAllowanceRefused;
76
+ export type JobPlanPhaseKind = "plan" | "work" | "review";
77
+ export type JobPlanPhaseStatus = "pending" | "running" | "blocked" | "done";
78
+ /** One phase of a job's plan, as the plan card spells it out. */
79
+ export type JobPlanPhase = {
80
+ id: string;
81
+ kind: JobPlanPhaseKind;
82
+ /** The model this phase is run on. */
83
+ model: string;
84
+ status: JobPlanPhaseStatus;
85
+ /** The card this phase has to leave behind before it may be done. */
86
+ artifact?: string;
87
+ /** What finishing it means, in the plan's own words. */
88
+ acceptance?: string;
89
+ /** Phase ids that have to be done first. */
90
+ dependsOn?: string[];
91
+ /** What the shift working this phase is told. */
92
+ brief?: string;
93
+ };
94
+ /**
95
+ * What is wrong with a plan card that could not be read.
96
+ *
97
+ * The parse issue names the line and what is wrong with it; a plain string is what a server
98
+ * that only has a sentence about it sends.
99
+ */
100
+ export type JobPlanIssue = string | {
101
+ line: number;
102
+ message: string;
103
+ };
104
+ export type JobPlanRead = {
105
+ ok: true;
106
+ raw: string;
107
+ phases: JobPlanPhase[];
108
+ } | {
109
+ ok: false;
110
+ reason: string;
111
+ issue?: JobPlanIssue;
112
+ };
113
+ /**
114
+ * One line of a job's journal.
115
+ *
116
+ * Loosely typed on purpose: the journal is a card the model writes as well as the runtime, and
117
+ * a reader that dropped the keys it did not know would hide exactly the entries worth reading.
118
+ */
119
+ export type JobJournalEntry = {
120
+ at: number;
121
+ kind?: string;
122
+ title?: string;
123
+ body?: string;
124
+ [key: string]: unknown;
125
+ };
126
+ /** What may be written to the owner's job settings. */
127
+ export type JobSettingsUpdate = {
128
+ /** A flat daily allowance, in USD. */
129
+ allowanceUsd?: number | null;
130
+ /** A daily allowance as a percentage of the owner's usage limit. */
131
+ allowancePercent?: number | null;
132
+ /** The autonomy new jobs start with. */
133
+ autonomy?: JobAutonomy | null;
134
+ /** Things every job of theirs asks about. */
135
+ alwaysAsk?: string[] | null;
136
+ };
137
+ /**
138
+ * The owner's job settings as they now stand.
139
+ *
140
+ * Open on purpose: the settings pipeline owns this document, and a setting it grows is worth
141
+ * handing back rather than dropping because the SDK has not been rebuilt.
142
+ */
143
+ export type JobSettings = JobSettingsUpdate & {
144
+ [key: string]: unknown;
145
+ };
146
+ export type JobListResponse = {
147
+ success: true;
148
+ jobs: JobView[];
149
+ page: number;
150
+ limit: number;
151
+ total: number;
152
+ /** The status the listing was filtered to, when it was. */
153
+ status?: JobStatus;
154
+ allowance: JobAllowance;
155
+ spentTodayUsd: number;
156
+ };
157
+ export type JobDetailResponse = {
158
+ success: true;
159
+ job: JobView;
160
+ /** The job's autonomy in a sentence, ready to show. */
161
+ autonomyLine: string;
162
+ plan: JobPlanRead;
163
+ journal: JobJournalEntry[];
164
+ /** Questions of this job's nobody has answered yet. */
165
+ openDeliveries: Delivery[];
166
+ };
167
+ export type JobCancelResponse = {
168
+ success: true;
169
+ job: JobView;
170
+ };
171
+ export type JobUpdateResponse = {
172
+ success: true;
173
+ job: JobView;
174
+ autonomyLine: string;
175
+ };
176
+ export type JobSettingsResponse = {
177
+ success: true;
178
+ jobs: JobSettings;
179
+ };
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JOB_AUTONOMY_MODES = exports.TERMINAL_JOB_STATUSES = exports.JOB_STATUSES = void 0;
4
+ exports.isJobAllowanceRefused = isJobAllowanceRefused;
5
+ /**
6
+ * A job: long-running work Alfred does on its own, run as a sequence of short shifts.
7
+ *
8
+ * These are the shapes the jobs routes answer with. The server flattens a job row into a
9
+ * `JobView` before it leaves, and this is that view rather than the row.
10
+ */
11
+ exports.JOB_STATUSES = [
12
+ "queued",
13
+ "running",
14
+ "blocked",
15
+ "waiting_user",
16
+ "waiting_approval",
17
+ "waiting_child",
18
+ "waiting_budget",
19
+ "review",
20
+ "done",
21
+ "failed",
22
+ "cancelled",
23
+ ];
24
+ /** The statuses a job never leaves. */
25
+ exports.TERMINAL_JOB_STATUSES = ["done", "failed", "cancelled"];
26
+ exports.JOB_AUTONOMY_MODES = ["ask", "free", "auto"];
27
+ /** Whether an allowance is a refusal rather than a grant. */
28
+ function isJobAllowanceRefused(allowance) {
29
+ return allowance.source === null;
30
+ }