@agentswf/engine 0.1.0-beta.5 → 0.1.0

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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +16 -0
  3. package/package.json +13 -9
  4. package/src/accounting/format.ts +4 -3
  5. package/src/accounting/spent.ts +128 -0
  6. package/src/accounting/summary.ts +40 -32
  7. package/src/agent-cli/cli.ts +28 -55
  8. package/src/agent-cli/client.ts +1 -27
  9. package/src/allowance-command.ts +8 -10
  10. package/src/attempt-ending.ts +4 -4
  11. package/src/attempt-output.ts +13 -31
  12. package/src/attempt-view.ts +33 -32
  13. package/src/author-typebox.ts +13 -0
  14. package/src/client/checks.ts +94 -0
  15. package/src/client/exported.ts +17 -0
  16. package/src/client/index.ts +122 -0
  17. package/src/client/locate.ts +34 -0
  18. package/src/client/published.ts +3 -0
  19. package/src/client/runs.ts +337 -0
  20. package/src/command-line.ts +64 -0
  21. package/src/commands.ts +7 -1
  22. package/src/control-plane.ts +4 -6
  23. package/src/coordinator-prompt.ts +4 -3
  24. package/src/coordinator-socket.ts +27 -28
  25. package/src/deadlines.ts +6 -40
  26. package/src/decisions/directory.ts +50 -37
  27. package/src/decisions/fake.ts +1 -1
  28. package/src/decisions/openrouter.ts +4 -2
  29. package/src/decisions/seam.ts +4 -4
  30. package/src/display-path.ts +3 -3
  31. package/src/duration.ts +19 -33
  32. package/src/emit-command.ts +38 -22
  33. package/src/emit.ts +148 -59
  34. package/src/event-ids.ts +31 -0
  35. package/src/files.ts +44 -4
  36. package/src/hand-over.ts +150 -0
  37. package/src/help/allowance.txt +4 -3
  38. package/src/help/emit.txt +11 -7
  39. package/src/help/inbox.txt +5 -3
  40. package/src/help/logs.txt +7 -5
  41. package/src/help/open.txt +29 -0
  42. package/src/help/reply.txt +6 -8
  43. package/src/help/run.txt +13 -8
  44. package/src/help/runs.txt +4 -2
  45. package/src/help/tick.txt +9 -4
  46. package/src/herdr-placement.ts +3 -33
  47. package/src/herdr-run-session.ts +5 -13
  48. package/src/herdr-workspace-marks.ts +7 -17
  49. package/src/holder-command.ts +54 -136
  50. package/src/in-lines.ts +28 -0
  51. package/src/inbox-command.ts +42 -56
  52. package/src/index.ts +1 -1
  53. package/src/init-command.ts +8 -17
  54. package/src/init-template/hello/workflow.ts.template +3 -2
  55. package/src/jsonl.ts +10 -6
  56. package/src/keep-alive.ts +7 -19
  57. package/src/logs-command.ts +134 -53
  58. package/src/message-operation.ts +6 -3
  59. package/src/messaging/messaging.ts +8 -10
  60. package/src/messaging/text.ts +2 -2
  61. package/src/open-command.ts +770 -0
  62. package/src/operation-events.ts +4 -6
  63. package/src/operation-liveness.ts +19 -4
  64. package/src/operator-cli.ts +206 -90
  65. package/src/operator-runtime.ts +24 -2
  66. package/src/ops.ts +270 -0
  67. package/src/pane-layout.ts +49 -96
  68. package/src/progress-view.ts +2 -2
  69. package/src/project-engine.ts +79 -0
  70. package/src/projects.ts +13 -2
  71. package/src/refusals.ts +119 -0
  72. package/src/result-slots.ts +11 -31
  73. package/src/result-validation.ts +2 -2
  74. package/src/run-command.ts +27 -31
  75. package/src/run-commands.ts +42 -114
  76. package/src/run-event-log.ts +1 -1
  77. package/src/run-lines.ts +159 -0
  78. package/src/run-lock.ts +57 -1
  79. package/src/run-policy/stage-plan.ts +154 -0
  80. package/src/run-policy/stages.ts +113 -0
  81. package/src/run-policy/waits.ts +452 -0
  82. package/src/run-prepare.ts +165 -41
  83. package/src/run-read.ts +109 -41
  84. package/src/run-state.ts +170 -50
  85. package/src/run-text.ts +54 -0
  86. package/src/run-unreachable.ts +18 -0
  87. package/src/run-usage.ts +17 -11
  88. package/src/run-view.ts +73 -14
  89. package/src/runs-command.ts +81 -63
  90. package/src/runs.ts +284 -222
  91. package/src/sandbox-homes.ts +65 -13
  92. package/src/sandboxes.ts +182 -28
  93. package/src/session-lines.ts +93 -0
  94. package/src/skills/run-skills.ts +8 -1
  95. package/src/sources.ts +2 -39
  96. package/src/spend.ts +333 -0
  97. package/src/stage-ledger.ts +111 -153
  98. package/src/stop-crashed.ts +6 -5
  99. package/src/stopped.ts +4 -4
  100. package/src/test-command.ts +37 -19
  101. package/src/testing/operation-events.ts +3 -23
  102. package/src/testing.ts +66 -18
  103. package/src/tick-command.ts +145 -15
  104. package/src/tools.ts +11 -30
  105. package/src/turn-ends.ts +37 -0
  106. package/src/waits.ts +651 -0
  107. package/src/wake.ts +84 -54
  108. package/src/workflow-loader.ts +49 -2
  109. package/src/workflow-runner.ts +525 -618
  110. package/src/workflow-test-preload.ts +4 -1
  111. package/src/workflow-testing/coordinators.ts +1 -1
  112. package/src/workflow-testing/fake-client.ts +387 -0
  113. package/src/workflow-testing/host.ts +28 -29
  114. package/src/workflow-testing/index.ts +9 -27
  115. package/src/workflow-testing/run-test.ts +201 -286
  116. package/src/workflow-testing/script.ts +7 -39
  117. package/src/deprecations.ts +0 -177
  118. package/src/hold-run-lock.fixture.ts +0 -8
  119. package/src/mailbox.ts +0 -972
  120. package/src/stage-plan.ts +0 -186
  121. package/src/workflow-testing/coordinator.typecheck.ts +0 -54
  122. package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
@@ -0,0 +1,119 @@
1
+ import { ClientError, type ClientErrorCode } from "@agentswf/contract/client";
2
+ import { REFUSAL_CODES, type RefusalCode, type RefusalJson } from "@agentswf/contract/records";
3
+ import type { InboxCode, ParticipantCode } from "@agentswf/contract/wire";
4
+ import { EmitError } from "./emit";
5
+ import { messageOf } from "./errors";
6
+ import { EngineRefused } from "./project-engine";
7
+ import { RunNotFound } from "./run-state";
8
+ import { isSystemError, RunUnreachable } from "./run-unreachable";
9
+ import { RunRefused } from "./runs";
10
+ import { WorkflowFileError } from "./workflow-loader";
11
+
12
+ /** Every code a command refuses with. */
13
+ type CommandCode = RefusalCode | ParticipantCode | InboxCode;
14
+
15
+ /**
16
+ * The codes only a command prints: a program's call has no argv and reads no run's socket, and
17
+ * `internal-error` is the command's word for an error with no code, which the client throws as it
18
+ * is.
19
+ */
20
+ const COMMAND_ONLY = new Set<RefusalCode>(
21
+ Object.keys({
22
+ usage: true,
23
+ ambiguous: true,
24
+ "not-started": true,
25
+ unreachable: true,
26
+ "internal-error": true,
27
+ "no-session": true,
28
+ live: true,
29
+ opened: true,
30
+ // Every code a client doesn't throw, so a new one is either a client's or listed here.
31
+ } satisfies Record<Exclude<RefusalCode, ClientErrorCode>, true>) as RefusalCode[],
32
+ );
33
+
34
+ /**
35
+ * The code an engine error is refused with, by a `--json` command and as a `ClientError` alike, so
36
+ * the two can't drift; none for an error that isn't the caller's.
37
+ */
38
+ function refusalCode(error: unknown): RefusalCode | undefined {
39
+ return error instanceof EmitError ||
40
+ error instanceof EngineRefused ||
41
+ error instanceof RunNotFound ||
42
+ error instanceof WorkflowFileError ||
43
+ error instanceof RunUnreachable ||
44
+ error instanceof RunRefused
45
+ ? error.code
46
+ : undefined;
47
+ }
48
+
49
+ /** `code` as a client's word, when it is one: what a command refused with, read back. */
50
+ export function clientCode(code: unknown): ClientErrorCode | undefined {
51
+ const known = (REFUSAL_CODES as readonly unknown[]).includes(code);
52
+ return known && !COMMAND_ONLY.has(code as RefusalCode) ? (code as ClientErrorCode) : undefined;
53
+ }
54
+
55
+ /**
56
+ * A failure while reading a run: the error's own code; else a system error, a file that couldn't
57
+ * be read, `unreachable`; else `internal-error`.
58
+ */
59
+ export function readFailureCode(error: unknown): RefusalCode {
60
+ const cause = error instanceof Error ? error.cause : undefined;
61
+ return (
62
+ refusalCode(error) ??
63
+ (cause === undefined ? undefined : refusalCode(cause)) ??
64
+ (isSystemError(error) ? "unreachable" : "internal-error")
65
+ );
66
+ }
67
+
68
+ /** An engine error that is the caller's mistake as a `ClientError`; anything else as it is. */
69
+ export function asClientError(error: unknown, ref?: string): unknown {
70
+ const code = refusalCode(error);
71
+ if (code === undefined || COMMAND_ONLY.has(code)) return error;
72
+ return new ClientError(code as ClientErrorCode, messageOf(error), ref);
73
+ }
74
+
75
+ /**
76
+ * A failure as a `--json` command prints it: the sentence on stderr, for people, and with `json`
77
+ * the refusal as stdout's last document. The code is the error's own, as `refusalCode` gives it;
78
+ * `otherwise` is the command's word for an error that has none. A caller's mistake never reaches a
79
+ * host as `internal-error`: it gets an error class `refusalCode` reads. `usage` follows the
80
+ * sentence.
81
+ */
82
+ export function printRefusal(
83
+ out: { stdout(text: string): void; stderr(text: string): void },
84
+ json: { json: boolean; v: number },
85
+ error: unknown,
86
+ otherwise: CommandCode,
87
+ usage?: string,
88
+ ): void {
89
+ const sentence = messageOf(error);
90
+ if (json.json) out.stdout(refusalLine(json.v, refusalCode(error) ?? otherwise, sentence));
91
+ out.stderr(`awf: ${sentence}${usage === undefined ? "" : `\n\n${usage}`}`);
92
+ }
93
+
94
+ /** A refusal as its one line on stdout, for a command whose stderr says it in its own words. */
95
+ export function refusalLine(
96
+ v: number,
97
+ code: CommandCode,
98
+ error: string,
99
+ choices?: string[],
100
+ ): string {
101
+ return JSON.stringify({
102
+ v,
103
+ code,
104
+ error,
105
+ ...(choices ? { choices } : {}),
106
+ } satisfies RefusalJson<CommandCode>);
107
+ }
108
+
109
+ /**
110
+ * Whether `argv` asks for `--json`, anywhere before `--`: what follows is a message's text or a
111
+ * workflow's arguments, which can't ask for it, and a usage error counts it before any parse.
112
+ */
113
+ export function asksJson(argv: readonly string[]): boolean {
114
+ const end = argv.indexOf("--");
115
+ return (end < 0 ? argv : argv.slice(0, end)).includes("--json");
116
+ }
117
+
118
+ /** No `--json`: the failure on stderr only. */
119
+ export const NO_JSON = { json: false, v: 0 };
@@ -4,11 +4,12 @@ import type { JsonSchema } from "@agentswf/contract/schema";
4
4
  import type { GivenInput, ResultSubmitCode } from "@agentswf/contract/wire";
5
5
  import type { AbsoluteDeadline } from "@agentswf/contract/workflow";
6
6
  import { scheduleAt } from "./deadlines";
7
+ import { messageOf } from "./errors";
8
+ import { recordAnswer, writeAcceptedExclusive } from "./ops";
7
9
  import { evaluateGiven } from "./result-validation";
8
- import { recordCandidate, writeAcceptedExclusive, writeCall } from "./runs";
9
10
 
10
- export type ResultRejectionCode = ResultSubmitCode;
11
- export type ResultSlotAccepted = {
11
+ type ResultRejectionCode = ResultSubmitCode;
12
+ type ResultSlotAccepted = {
12
13
  kind: "accepted";
13
14
  value: unknown;
14
15
  candidateRecorded: boolean;
@@ -18,8 +19,8 @@ export type ResultSlotAccepted = {
18
19
  acceptedAt: number;
19
20
  };
20
21
  type Rejected = { kind: "rejected"; code: ResultRejectionCode; error: string };
21
- export type ResultSubmission = ResultSlotAccepted | Rejected;
22
- export type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline?: number };
22
+ type ResultSubmission = ResultSlotAccepted | Rejected;
23
+ type WaitingGrant = { kind: "waiting"; waitUntil: number; deadline?: number };
23
24
  export type ResultSlotSettlement =
24
25
  | ResultSlotAccepted
25
26
  | { kind: "closed" }
@@ -29,7 +30,7 @@ export type ResultSlotEvent =
29
30
  | { kind: "waiting"; reason: string; waitUntil: number; at: number; revision: number }
30
31
  | { kind: "admitted"; admittedAt: number; revision: number }
31
32
  | { kind: "persisted"; accepted: ResultSlotAccepted; revision: number };
32
- export type ResultSlotBinding = { operationId: string; settled: Promise<ResultSlotSettlement> };
33
+ type ResultSlotBinding = { operationId: string; settled: Promise<ResultSlotSettlement> };
33
34
  export type ResultSlotSpec = {
34
35
  operationId: string;
35
36
  agentId: string;
@@ -52,8 +53,7 @@ export type ResultSlotRegistryOptions = {
52
53
  waitMinimumMs?: number;
53
54
  };
54
55
  interface ResultSlotPersistence {
55
- writeCall: typeof writeCall;
56
- recordCandidate: typeof recordCandidate;
56
+ recordAnswer: typeof recordAnswer;
57
57
  writeAcceptedExclusive: typeof writeAcceptedExclusive;
58
58
  }
59
59
  type SubmissionInput = {
@@ -84,7 +84,7 @@ type Slot = ResultSlotSpec & {
84
84
  export function createResultSlotRegistry(options: ResultSlotRegistryOptions): ResultSlotRegistry {
85
85
  const now = options.now ?? Date.now;
86
86
  const schedule = options.schedule ?? scheduleExpiry;
87
- const persistence = options.persistence ?? { writeCall, recordCandidate, writeAcceptedExclusive };
87
+ const persistence = options.persistence ?? { recordAnswer, writeAcceptedExclusive };
88
88
  const persistenceMs = duration(options.persistenceMs, 5_000);
89
89
  const auditMs = duration(options.auditMs, 1_000);
90
90
  const waitDefaultMs = duration(options.waitDefaultMs, 120_000);
@@ -106,7 +106,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
106
106
  try {
107
107
  await bounded(
108
108
  Promise.resolve().then(() =>
109
- persistence.recordCandidate(options.runDir, slot.operationId, {
109
+ persistence.recordAnswer(options.runDir, slot.operationId, {
110
110
  at: new Date(now()).toISOString(),
111
111
  source: input.source,
112
112
  // Flags as given, whether or not they convert, so the record shows what the agent typed.
@@ -143,26 +143,6 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
143
143
  });
144
144
  const slot: Slot = { ...spec, state: "open", settle, cancelExpiry: () => {}, revision: 0 };
145
145
  slots.set(spec.operationId, slot);
146
- try {
147
- await bounded(
148
- Promise.resolve().then(() =>
149
- persistence.writeCall(options.runDir, {
150
- callId: spec.operationId,
151
- question: spec.question,
152
- ...(spec.schema ? { schema: spec.schema } : {}),
153
- }),
154
- ),
155
- persistenceMs,
156
- "call persistence timed out",
157
- );
158
- } catch (error) {
159
- if (error instanceof SlotTimeoutError) {
160
- // The old write may still publish; its operation ID must never be reused.
161
- slot.state = "failed";
162
- slot.settle({ kind: "failed", reason: error.message });
163
- } else slots.delete(spec.operationId);
164
- throw error;
165
- }
166
146
  if (slot.state === "open") expireAt(slot);
167
147
  return { operationId: spec.operationId, settled };
168
148
  },
@@ -253,7 +233,7 @@ export function createResultSlotRegistry(options: ResultSlotRegistryOptions): Re
253
233
  },
254
234
  () => {},
255
235
  );
256
- const reason = error instanceof Error ? error.message : String(error);
236
+ const reason = messageOf(error);
257
237
  if (slot.state === "committing") {
258
238
  slot.state = "failed";
259
239
  slot.settle({ kind: "failed", reason });
@@ -1,5 +1,5 @@
1
1
  import { acceptAny, type SemanticCheck } from "@agentswf/contract";
2
- import type { CallSpec } from "@agentswf/contract/records";
2
+ import type { JsonSchema } from "@agentswf/contract/schema";
3
3
  import type { GivenInput } from "@agentswf/contract/wire";
4
4
  import { checkGiven, readGiven } from "./simple-input";
5
5
 
@@ -12,7 +12,7 @@ export type CandidateEvaluation =
12
12
  * owns acceptance.
13
13
  */
14
14
  export async function evaluateGiven(
15
- call: Pick<CallSpec, "question" | "schema">,
15
+ call: { question: string; schema?: JsonSchema },
16
16
  given: GivenInput,
17
17
  semantic: SemanticCheck = acceptAny,
18
18
  ): Promise<CandidateEvaluation> {
@@ -7,7 +7,10 @@ import { messageOf } from "./errors";
7
7
  import help from "./help/run.txt" with { type: "text" };
8
8
  import { SESSION_CODE } from "./here";
9
9
  import { KEEP_PANES, parseWorkspace } from "./pane-layout";
10
- import { idProblem, runRootOf, stageNameProblem } from "./runs";
10
+ import { idProblem, RunRefused, runRootOf, stageNameProblem } from "./runs";
11
+
12
+ /** `awf run --json`'s `"v"`, which its refusals carry too. */
13
+ export const RUN_V = 1;
11
14
 
12
15
  export const usage = help.trimEnd();
13
16
 
@@ -47,17 +50,8 @@ export type RunCommand = {
47
50
  session?: string;
48
51
  };
49
52
 
50
- /** `awf run`'s options and workflow; `"help"` for a `-h` or `--help` anywhere before `--`. */
51
- export function parseCommand(
52
- argv: readonly string[],
53
- cwd: string,
54
- /** Told each deprecated form given, once. */
55
- deprecated: (line: string) => void = () => {},
56
- ): RunCommand | "help" {
57
- if (argv[0] !== "run") throw new Error("expected the run command");
58
- const end = argv.indexOf("--");
59
- const own = end < 0 ? argv : argv.slice(0, end);
60
- if (own.includes("-h") || own.includes("--help")) return "help";
53
+ /** `awf run`'s options and workflow. */
54
+ export function parseCommand(argv: readonly string[], cwd: string): RunCommand {
61
55
  let timeout: string | undefined;
62
56
  let timeoutMilliseconds: number | undefined;
63
57
  let runRoot: string | undefined;
@@ -102,7 +96,7 @@ export function parseCommand(
102
96
  const value = argv[index + 1];
103
97
  if (option === "--timeout") {
104
98
  if (!value) throw new Error("--timeout needs a duration such as 30m or 1d");
105
- timeoutMilliseconds = parseDuration(value, "--timeout", deprecated);
99
+ timeoutMilliseconds = parseDuration(value, "--timeout");
106
100
  timeout = value;
107
101
  } else if (option === "--run-root") {
108
102
  if (!value) throw new Error("--run-root needs a directory");
@@ -117,7 +111,8 @@ export function parseCommand(
117
111
  } else if (option === "--id") {
118
112
  if (!value) throw new Error("--id needs a run's id");
119
113
  const problem = idProblem(value);
120
- if (problem) throw new Error(`--id: ${problem}`);
114
+ // Refused as any id the grammar refuses is, `id`, still a usage error's exit.
115
+ if (problem) throw new RunRefused(`--id: ${problem}`, "id");
121
116
  named = value;
122
117
  } else if (option === "--from-stage") {
123
118
  if (!value) throw new Error("--from-stage needs a stage's name");
@@ -153,6 +148,10 @@ export function parseCommand(
153
148
  }
154
149
  if (!workflowFile) throw new Error("run needs one workflow file");
155
150
  if (here && session !== undefined) throw new Error("--here and --session do not go together");
151
+ if (here && json)
152
+ throw new Error(
153
+ "--here prints what the calling agent does next, no document: leave out --json",
154
+ );
156
155
  if (continuing && named === undefined)
157
156
  throw new Error("--continue goes on with the run --id names");
158
157
  const id = continuing ? undefined : named;
@@ -186,15 +185,7 @@ export function parseCommand(
186
185
 
187
186
  /** `--values`'s file: a JSON object of stage values, by stage name. */
188
187
  function readValues(file: string): NonNullable<RunCommand["values"]> {
189
- let parsed: unknown;
190
- try {
191
- parsed = JSON.parse(readFileSync(file, "utf8"));
192
- } catch (error) {
193
- throw new Error(`--values: ${messageOf(error)}`);
194
- }
195
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
196
- throw new Error(`--values: ${file} must hold a JSON object, each stage's value by its name`);
197
- }
188
+ const parsed = readJsonObject(file, "--values", "each stage's value by its name");
198
189
  for (const stage of Object.keys(parsed)) {
199
190
  const problem = stageNameProblem(stage);
200
191
  if (problem) throw new Error(`--values: ${problem}`);
@@ -202,17 +193,22 @@ function readValues(file: string): NonNullable<RunCommand["values"]> {
202
193
  return { file, stages: new Map(Object.entries(parsed as Record<string, JsonValue>)) };
203
194
  }
204
195
 
205
- /** `--sandbox`'s file: an inline sandbox spec, whose working directory is the run's. */
206
- function readSandboxSpec(file: string): object {
207
- let spec: unknown;
196
+ /** `flag`'s file, which must hold a JSON object: `what`, as its refusal says. */
197
+ function readJsonObject(file: string, flag: string, what: string): object {
198
+ let parsed: unknown;
208
199
  try {
209
- spec = JSON.parse(readFileSync(file, "utf8"));
200
+ parsed = JSON.parse(readFileSync(file, "utf8"));
210
201
  } catch (error) {
211
- throw new Error(`--sandbox: ${messageOf(error)}`);
212
- }
213
- if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
214
- throw new Error(`--sandbox: ${file} must hold a JSON object, a sandbox spec`);
202
+ throw new Error(`${flag}: ${messageOf(error)}`);
215
203
  }
204
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
205
+ throw new Error(`${flag}: ${file} must hold a JSON object, ${what}`);
206
+ return parsed;
207
+ }
208
+
209
+ /** `--sandbox`'s file: an inline sandbox spec, whose working directory is the run's. */
210
+ function readSandboxSpec(file: string): object {
211
+ const spec = readJsonObject(file, "--sandbox", "a sandbox spec");
216
212
  for (const [field, why] of Object.entries(NOT_IN_A_RUN_SANDBOX)) {
217
213
  if (field in spec) throw new Error(`--sandbox: the run's sandbox names no ${field}; ${why}`);
218
214
  }
@@ -1,98 +1,34 @@
1
+ // What the commands that read runs say of one: its state, span and cost, and the commands that go
2
+ // on from it.
1
3
  import { resolve } from "node:path";
2
- import type { AttemptRecord, RunLogLine } from "@agentswf/contract/records";
3
- import { maskCredentials } from "@agentswf/harness";
4
- import { continueCommand, shellWord } from "./attempt-view";
4
+ import type { RunLogLine } from "@agentswf/contract/records";
5
+ import { runEstimate, unknownOps } from "./accounting/spent";
6
+ import { continueCommand } from "./attempt-view";
7
+ import { command, type Out, placeFlags, type ReadPlace, shellLine } from "./command-line";
8
+ import { NO_JSON, readFailureCode, refusalLine } from "./refusals";
5
9
  import type { RunCommand } from "./run-command";
6
10
  import { type Cursor, encodeCursor } from "./run-read";
7
11
  import { RunNotFound, type RunStatus } from "./run-state";
12
+ import { clockTime } from "./run-text";
8
13
  import { runRootOf } from "./runs";
14
+ import { readSpent } from "./spend";
9
15
 
10
- /** What `awf runs` and `awf logs` share: where they look, and how they say a run. */
11
-
12
- /** Where the commands that read runs look, and the options their suggested commands repeat. */
13
- export type ReadPlace = { shellCwd: string; runRoot: string; repeat: string[] };
14
-
15
- export type Out = { stdout(text: string): void; stderr(text: string): void };
16
-
17
- /** `--cwd`, `--run-root` and `--json`, taken out of `argv`; the rest is left for the command. */
18
- export function parsePlace(
19
- argv: readonly string[],
20
- shellCwd: string,
21
- ): { place: ReadPlace; json: boolean; rest: string[] } {
22
- let cwd = shellCwd;
23
- let runRoot: string | undefined;
24
- let json = false;
25
- const repeat: string[] = [];
26
- const rest: string[] = [];
27
- for (let index = 0; index < argv.length; index += 1) {
28
- const arg = argv[index]!;
29
- // What follows is the command's own, a message's text included.
30
- if (arg === "--") {
31
- rest.push(...argv.slice(index));
32
- break;
33
- }
34
- if (arg === "--json") json = true;
35
- else if (arg === "--cwd" || arg === "--run-root") {
36
- const value = argv[index + 1];
37
- if (!value || value.startsWith("--")) throw new Error(`${arg} needs a directory`);
38
- index += 1;
39
- if (arg === "--cwd") cwd = resolve(shellCwd, value);
40
- else runRoot = resolve(shellCwd, value);
41
- repeat.push(arg, value);
42
- } else rest.push(arg);
43
- }
44
- return { place: { shellCwd, runRoot: runRoot ?? runRootOf(cwd), repeat }, json, rest };
45
- }
46
-
47
- /** A suggested command, with the place repeated. */
48
- export function command(place: ReadPlace, ...words: string[]): string[] {
49
- const [first, second, ...rest] = words;
50
- return [first!, second!, ...rest.slice(0, 1), ...place.repeat, ...rest.slice(1)];
51
- }
52
-
53
- /** A field whose name says it holds a credential, whatever its value looks like. */
54
- const SECRET_NAME =
55
- /password|passwd|passphrase|passcode|secret|token|credential|cookie|bearer|authorization|(?:api|access|private|session|ssh|signing|encryption)[_-]?key|^(?:pwd|pass|pin|auth)$/i;
56
-
57
- /**
58
- * Every string in `value` masked, and everything under a credential's name hidden: a run's text,
59
- * errors and fields are its workflow's, its agents' and its tools'. Past `limit`, a string is cut.
60
- */
61
- export function masked(value: unknown, limit = Number.POSITIVE_INFINITY, hidden = false): unknown {
62
- if (typeof value === "string") {
63
- if (hidden) return "***";
64
- const shown = maskCredentials(value);
65
- return shown.length > limit ? `${shown.slice(0, limit - 1)}…` : shown;
66
- }
67
- if (hidden && value !== null && typeof value !== "object") return "***";
68
- if (Array.isArray(value)) return value.map((each) => masked(each, limit, hidden));
69
- if (value && typeof value === "object") {
70
- return Object.fromEntries(
71
- Object.entries(value).map(([key, each]) => [
72
- key,
73
- masked(each, limit, hidden || SECRET_NAME.test(key)),
74
- ]),
75
- );
76
- }
77
- return value;
78
- }
79
-
80
- /**
81
- * Text a run wrote, as one masked line of about `limit` characters, so it can never pass for a
82
- * line of its own or fill a page. `@{handle} --full` prints it whole.
83
- */
84
- export function said(text: string, limit = 120): string {
85
- const line = maskCredentials(text).replace(/\s*[\r\n]+\s*/g, " ⏎ ");
86
- return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
87
- }
88
-
89
- /** A suggested command as text to paste into a shell. */
90
- export function shellLine(words: readonly string[]): string {
91
- return words.map(shellWord).join(" ");
92
- }
16
+ /** The longest `--wait` the commands that read runs take. */
17
+ export const MAX_WAIT_SECONDS = 300;
18
+ /** How often they read a run while they wait. */
19
+ export const POLL_MS = 1_000;
93
20
 
94
21
  /** Says a run that is not there, and exits 1; anything else is the run's files, exit 3. */
95
- export function failed(error: unknown, place: ReadPlace, out: Out, message: string): number {
22
+ export function failed(
23
+ error: unknown,
24
+ place: ReadPlace,
25
+ out: Out,
26
+ message: string,
27
+ json: { json: boolean; v: number } = NO_JSON,
28
+ ): number {
29
+ // The error's own code; else a file that couldn't be read, exit 3's, or a failure of awf's.
30
+ const code = readFailureCode(error);
31
+ if (json.json) out.stdout(refusalLine(json.v, code, message));
96
32
  if (error instanceof RunNotFound) {
97
33
  out.stderr(`${error.message}; runs here: ${shellLine(command(place, "awf", "runs"))}`);
98
34
  return 1;
@@ -114,16 +50,6 @@ export function startedLine(
114
50
  return `awf: ${ref} started · watch: ${shellLine(["awf", "logs", ref, ...placeFlags(run)])}`;
115
51
  }
116
52
 
117
- /** The flags that find this run again from another command: the folder or run root given. */
118
- export function placeFlags(
119
- run: Pick<RunCommand, "cwd" | "cwdGiven" | "runRoot" | "runRootGiven">,
120
- ): string[] {
121
- return [
122
- ...(run.cwdGiven ? ["--cwd", run.cwd] : []),
123
- ...(run.runRootGiven ? ["--run-root", run.runRoot] : []),
124
- ];
125
- }
126
-
127
53
  /** `awf logs` from `cursor`: with `--wait` unless the page stopped short of the newest. */
128
54
  export function nextLogs(
129
55
  status: RunStatus,
@@ -205,22 +131,24 @@ export function span(
205
131
  return { started, ...(ended === undefined ? {} : { ended }), ms: (ended ?? now) - started };
206
132
  }
207
133
 
208
- /** At list prices, over the attempts that kept one: never what was billed. */
209
- export function estimateOf(attempts: readonly AttemptRecord[]): number | undefined {
210
- const known = attempts.flatMap((each) =>
211
- each.accounting?.totals.estimate === undefined ? [] : [each.accounting.totals.estimate],
134
+ /**
135
+ * At list prices, today's, over the run's agent operations that recorded their spend, never what
136
+ * was billed; and how many operations' usage isn't known, which makes it a floor.
137
+ */
138
+ export async function estimateOf(
139
+ status: RunStatus,
140
+ ): Promise<{ estimate?: number; unknown: number }> {
141
+ const spent = await readSpent(status.run.dir, status.attempts, { decisions: false }).catch(
142
+ () => undefined,
212
143
  );
213
- return known.length > 0 ? known.reduce((total, each) => total + each, 0) : undefined;
214
- }
215
-
216
- /** `$6.10`; `<$0.01` for less, and nothing for none. */
217
- export function money(estimate: number | undefined): string[] {
218
- if (!estimate) return [];
219
- return [estimate < 0.005 ? "<$0.01" : `$${estimate.toFixed(2)}`];
220
- }
221
-
222
- export function clockTime(at: number | string): string {
223
- const date = new Date(at);
224
- const two = (n: number) => String(n).padStart(2, "0");
225
- return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
144
+ if (!spent) return { unknown: 0 };
145
+ const estimate = runEstimate(spent);
146
+ // A running attempt's operations are read as it ends: not yet unknown.
147
+ const live = status.state === "running" ? status.attempts.at(-1)?.attempt : undefined;
148
+ return {
149
+ ...(estimate === undefined ? {} : { estimate }),
150
+ unknown: unknownOps(
151
+ spent.map((each) => (each.attempt === live ? { ...each, unsettled: 0 } : each)),
152
+ ),
153
+ };
226
154
  }
@@ -41,7 +41,7 @@ export class RunEventLog {
41
41
  if (this.#closed) return;
42
42
  this.#writes.push(
43
43
  appendEvent(this.options.runDir, record).catch((error) =>
44
- this.options.failed(`awf: a turn was not recorded in turns.jsonl: ${messageOf(error)}`),
44
+ this.options.failed(`awf: a turn was not recorded in log.jsonl: ${messageOf(error)}`),
45
45
  ),
46
46
  );
47
47
  }