@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
package/src/help/emit.txt CHANGED
@@ -1,11 +1,12 @@
1
- usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--json]
2
- awf emit <run> <key>:reply '{"text": …, "author": …}' [--event-id <id>] [--cwd <directory>] [--run-root <directory>] [--json]
1
+ usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--source <name>] [--json]
2
+ awf emit <run> <key>:reply '{"text": …, "author": …}' [--event-id <id>] [--source <name>] [--cwd <directory>] [--run-root <directory>] [--json]
3
3
 
4
4
  Sends an event to the run of a workflow with events that --id names. That run starts if it has
5
5
  none and the event is one that starts one, and is woken if it has one: told while it runs,
6
6
  continued when it is parked or crashed. The run lives in the project of <file>, the nearest
7
7
  directory above it holding .awf/, whoever sends. --event-id names the occurrence: the same id
8
- again is a duplicate.
8
+ again is a duplicate. --source names the integration that sent it, such as github, which the
9
+ event a wait returns carries.
9
10
 
10
11
  The second form sends a participant's message to the code of a run that is there, <workflow>/<id>
11
12
  or an <id> only one workflow has, here or in a project awf knows: <key>:reply, which the workflow
@@ -15,7 +16,10 @@ arrive before the run connects the participant: it waits for the run's next wait
15
16
  takes only the workflow's own events.
16
17
 
17
18
  Prints what became of the event, and what was done to the run: accepted, duplicate or refused.
18
- --json prints one document. Exits 0 for any of the three; 1 when <file> could not be loaded, or
19
- the run isn't found, and nothing is kept, or when the run's attempt could not be started (the
20
- event is kept, and the next event or awf tick tries again); 2 on a usage error, for a workflow
21
- with no events, or when the event is wrong for its target: its name, its data, or its id.
19
+ --json prints one document: the result, or a refusal whose code says why it failed: usage,
20
+ no-run, ambiguous, id, file, event, prepare, project, no-engine, other-engine, unreachable or
21
+ internal-error. Exits 0 for any of the three; 1 when <file> could not be loaded, or the run
22
+ isn't found or can't be read, and nothing is kept, or when the run's attempt could not be
23
+ started (the event is kept, and the next event or awf tick tries again); 2 on a usage error, for
24
+ a workflow with no events, or when the event is wrong for its target: its name, its data, or its
25
+ id.
@@ -4,9 +4,11 @@ usage: awf inbox [<run>] [--open] [--workflow <name>] [--as <key>] [--after <cur
4
4
  What agents said to a participant outside the run, and what it said back: routes, messages,
5
5
  answers, questions that closed unanswered, and each attempt's end, oldest first. Without a run,
6
6
  every run here that connected one; --workflow narrows that. A run is <workflow>/<id>, or an <id>
7
- only one workflow has. --after reads on from a cursor the last answer gave; --wait
8
- waits up to that long, at most 5m, when nothing is new. --open lists the questions
9
- still waiting for an answer, a snapshot. --json prints one item per line.
7
+ only one workflow has. --after reads on from a cursor the last answer gave; --wait waits up to
8
+ that long, at most 5m, when nothing is new. --open lists the questions still waiting for an
9
+ answer, a snapshot. --json prints one item per line, and when it fails a refusal last whose code
10
+ says why: usage, no-run, ambiguous, id, cursor, no-participant, unreachable, no-engine,
11
+ other-engine or internal-error.
10
12
 
11
13
  Exits 0 on a read that worked, 1 when there is no such run, it has no participant, or the
12
14
  cursor is another's, 2 on a usage error, 3 when the run's files could not be read.
package/src/help/logs.txt CHANGED
@@ -6,9 +6,11 @@ What a run did and is doing: its events and what its agents did, read from their
6
6
  files, merged by time. With no run, the newest here. Without --after, the last 40 events; with
7
7
  it, what came after, oldest first. Every answer ends with the command that gets only what is
8
8
  new. --wait: with nothing to show yet, wait for the first event, at most that long, up to 5m,
9
- such as 90s (a bare number, seconds, is deprecated); --follow prints each event as it comes until
10
- the run ends. @<handle> names one event, and --full prints its text, with common credential
11
- shapes masked; --json carries it always.
9
+ such as 90s; --follow prints each event as it comes until the run ends. @<handle> names one
10
+ event, and --full prints its text, with common credential shapes masked; --json carries it
11
+ always.
12
12
 
13
- Exits 0 on any read that worked, 1 when there is no such run or the cursor or handle does not
14
- fit it, 2 on a usage error, 3 when the run's files could not be read.
13
+ With --json, a failure prints a refusal whose code says why: usage, no-run, ambiguous, id,
14
+ cursor, unreachable, no-engine, other-engine or internal-error. Exits 0 on any read that worked,
15
+ 1 when there is no such run or the cursor or handle does not fit it, 2 on a usage error, 3 when
16
+ the run's files could not be read.
@@ -0,0 +1,29 @@
1
+ usage: awf open <run> <agent> [--no-fork] [--session <id>] [-p [<text>]]
2
+ [--cwd <directory>] [--run-root <directory>] [--json]
3
+
4
+ A run is <workflow>/<id>, or an <id> only one workflow has, here or in a project awf knows.
5
+ Without an agent, it prints the command that opens each of the run's agents.
6
+ Opens a copy of an agent's latest session in its harness's own TUI, where the agent worked: its
7
+ working directory, its home and skills, its sandbox, on the model and effort it ran on, with the
8
+ harness's own permission prompts. The copy is made without asking the model, and is yours: the
9
+ run and the agent's session are unchanged, and awf keeps no record of it.
10
+
11
+ --no-fork opens the session itself: what you say stays with the agent, awf logs shows it, and the
12
+ next attempt resumes it. It is refused while the run is live; while it is open, no attempt starts
13
+ and an event waits, and once the harness exits a run with events is woken for it.
14
+ --session opens another session in the agent's place: a copy an earlier awf open made, as it is;
15
+ or one of the agent's earlier sessions, as its latest would be.
16
+ -p asks one question headless and prints the answer; the text is its argument, or stdin without
17
+ one. It runs as the agent's turns ran, without prompts, in its sandbox when it had one; on
18
+ claude, billed per token headless, it is refused unless the agent ran metered.
19
+ As the harness exits, awf prints the command that opens the same session again.
20
+
21
+ With --json, -p prints {"v": 1, "session", "forkedFrom"?, "reply", "again"}, or a refusal whose
22
+ code says why: usage, no-run, ambiguous, id, no-session, live, opened, unreachable, no-engine,
23
+ other-engine or internal-error. no-session is an agent with no session or operation to open;
24
+ live, --no-fork, or a sandboxed agent, on a running run, or a --no-fork whose session changed as
25
+ it held the run; opened, a run another awf open --no-fork holds, or a sandbox another awf open
26
+ has open; internal-error, a copy or a -p question that failed. A TUI has no JSON: --json without
27
+ -p is a usage error. Exits with the TUI's exit code, or 0 once -p printed its answer; 1 when
28
+ refused or failed; 2 on a usage error, or a -p or a TUI the agent's harness can't give; 3 when
29
+ the run or the agent's place could not be read, or awf itself failed.
@@ -1,6 +1,5 @@
1
1
  usage: awf reply <run> <agent> [--to <id>] [--as <participant>] [<text>]
2
2
  awf send <run> <agent> [--as <participant>] [<text>]
3
- awf send <run> --as <participant> --event-id <id> [--author <name>] [<text>]
4
3
  each takes [--cwd <directory>] [--run-root <directory>] [--json]
5
4
 
6
5
  A run is <workflow>/<id>, or an <id> only one workflow has, here or in a project awf knows.
@@ -8,11 +7,10 @@ Answers a question an agent asked a participant outside the run, or messages an
8
7
  over a route the workflow allowed. The text is the argument, or stdin without one. --to names
9
8
  the question, as awf inbox prints it; it may be left out when the agent has one open. --as
10
9
  names the participant when the workflow connects more than one. Neither waits for an answer:
11
- it comes back in awf inbox. send --event-id is deprecated: awf emit <run> <participant>:reply
12
- '{"text":…,"author":…}' --event-id <id> replaces it, and it forwards there. It still sends a
13
- message on a participant to the run's code, the id the message's own, and prints as awf emit
14
- does; it exits 0 for whatever the run did with it (accepted, duplicate, or refused by a run that
15
- returned); 1 when the run or participant isn't found, or its attempt didn't start.
10
+ it comes back in awf inbox. A message on a participant to the run's code is sent with awf emit
11
+ <run> <participant>:reply '{"text":…,"author":…}' --event-id <id>.
16
12
 
17
- Exits 0 when delivered, 1 when refused (the code says why), 2 on a usage error, 3 when the run
18
- could not be reached.
13
+ With --json, each prints {"v": 3, "id": …} when delivered, or a refusal whose code says why: the
14
+ participant's, or usage, no-run, ambiguous, id, unreachable, no-engine, other-engine or
15
+ internal-error. Exits 0 when delivered, 1 when refused (the code says why), 2 on a usage error,
16
+ 3 when the run could not be reached.
package/src/help/run.txt CHANGED
@@ -13,14 +13,20 @@ A new run with --from-stage starts there. A stage before it with no record to re
13
13
  value from --values, a JSON object by stage name, checked by its result; one that returns
14
14
  nothing is passed. Without one the attempt stops, naming the stage and its schema.
15
15
  A run is kept in .awf/runs/<workflow>/<id> under --cwd, unless --run-root names another folder
16
- for .awf/runs.
16
+ for .awf/runs. A workflow that gives file: import.meta.path keeps its runs in its file's project
17
+ unless --run-root names another folder: the nearest directory at or above the file holding .awf/,
18
+ else the file's own, so --continue finds a run a program started there from anywhere.
17
19
  A run has no deadline unless --timeout gives it one, per attempt: a duration, a decimal and s, m,
18
20
  h or d, such as 20m or 1d.
19
21
  A workflow that knows how to present its result prints that; --json prints the full result instead.
20
- Either way the full result is kept as output.json in the run's folder, beside report.md
22
+ Either way the full result is kept as out.json in the run's folder, beside report.md
21
23
  when the workflow writes one. A run that fails or is cancelled once its agents have started keeps
22
- output.json too, with what it spent and why it ended; --json prints it. A second Ctrl-C stops
23
- awf at once, without it.
24
+ out.json too, with what it spent and why it ended; --json prints it. When no run comes of it,
25
+ --json prints a refusal whose code says why: usage, file, prepare, no-id, id, no-run,
26
+ other-args, not-started, unreachable, project, no-engine, other-engine or internal-error;
27
+ --here takes no --json, nor does opening a run of a workflow with events, which prints no
28
+ document. A second Ctrl-C stops awf at once, without it; a continue then reads
29
+ what its agents spent.
24
30
  --cwd sets the directory the workflow and its agents work in; it defaults to the current one.
25
31
  --sandbox puts every agent of the run in one sandbox, working in --cwd; the file is a JSON spec
26
32
  such as {"read": ["/data/request.md"], "srt": {}}, naming srt or docker, whose relative paths are
@@ -31,10 +37,9 @@ panes; --no-watch leaves it out, and awf still prints the command that shows the
31
37
  default); origin, the one awf run was typed in; or any other word, the workspace with that label,
32
38
  found or made. --keep-panes says whether an agent's pane stays once it is done: never (the
33
39
  default), on-failure, when its last turn wasn't answered, or always. A kept pane's harness is left
34
- running, and yours. Both are kept with the run for every attempt, a woken one too, and win over a
35
- workflow's deprecated layout and keepPane. A run an event started has neither, and takes the
36
- defaults. A continue may give either again with the run's value, or its default where the run had
37
- none, and no other.
40
+ running, and yours. Both are kept with the run for every attempt, a woken one too. A run an event
41
+ started has neither, and takes the defaults. A continue may give either again with the run's
42
+ value, or its default where the run had none, and no other.
38
43
  --here, run by an agent in a Herdr pane, starts the run in a new tab and has it take that agent's
39
44
  session over as one of its agents, from its next turn until the run ends. It prints a line for
40
45
  the agent to end its turn with, which is how the run finds the session.
package/src/help/runs.txt CHANGED
@@ -7,5 +7,7 @@ number. A run is <workflow>/<id>, or an <id> only one
7
7
  workflow has. Runs are listed under --cwd's .awf/runs, or --run-root, as awf run keeps them; a
8
8
  run named is found there first, then in each project awf has started a run with events in.
9
9
 
10
- --json prints one document, the interface for scripts. Exits 0 on any read that worked, 1 when
11
- there is no such run, 2 on a usage error, 3 when the run's files could not be read.
10
+ --json prints one document, the interface for scripts: when it fails, a refusal whose code says
11
+ why: usage, no-run, ambiguous, id, unreachable, no-engine, other-engine or internal-error. Exits
12
+ 0 on any read that worked, 1 when there is no such run, 2 on a usage error, 3 when the run's
13
+ files could not be read.
package/src/help/tick.txt CHANGED
@@ -5,8 +5,13 @@ Wakes every run of a workflow with events under --cwd's .awf/runs, or --run-root
5
5
  runs lists there, as an event would: continues a parked run whose wait's timer is due, and a
6
6
  crashed or failed one until it has failed three times in a row. Runs of other workflows are left
7
7
  as they are. --all wakes them in every project awf has started one in, as ~/.awf/projects lists
8
- them. Run it on any schedule; a run is continued once however many wakers come at once.
8
+ them. A project's runs are woken by its own agentswf: one at another version than this awf runs
9
+ its own awf tick for them. Run it on any schedule; a run is continued once however many wakers
10
+ come at once.
9
11
 
10
- Prints a line for each run it woke, and nothing when it woke none. --json prints one document.
11
- Exits 0; 1 when a project is gone or a run could not be woken, the others still woken; 2 on a
12
- usage error.
12
+ Prints a line for each run it woke, and nothing when it woke none, and one line on stderr
13
+ counting runs with events it can't read, which it doesn't wake; --json prints one document,
14
+ those runs under unreadable, on exit 1 too, or a refusal whose code says why it failed: usage or
15
+ internal-error. Exits 0; 1 when a project is gone, declares agentswf without installing it, or
16
+ its own agentswf failed, or a run could not be woken, the others still woken; 2 on a usage
17
+ error.
@@ -2,46 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import { mkdir, rename, rmdir, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { HERDR_VERSION, type RunProcess } from "@agentswf/harness";
5
- import { messageOf } from "./errors";
6
- import { ensureRunSession, parseSessions, type RunSessionDeps } from "./herdr-run-session";
5
+ import { isCode } from "./files";
6
+ import { parseSessions } from "./herdr-run-session";
7
7
  import { machinePaths } from "./machine";
8
8
 
9
- /** Where a layout may open a session awf does not otherwise use: one named `awf-…` it starts. */
10
- const OWN_SESSION = /^awf(-|$)/;
11
9
  const LOCK_POLL_MS = 100;
12
10
  /** Held inside a session's queue: a short wait, then made anyway. */
13
11
  const LOCK_WAIT_MS = 5_000;
14
12
  /** A lock older than this was left by a run that died holding it. */
15
13
  const LOCK_STALE_MS = 30_000;
16
14
 
17
- /**
18
- * Whether a session a layout names can be used: undefined when it can, else why not. One named
19
- * `awf-…` is started, swept and restarted as the run session is; any other must be running. Either
20
- * must answer, on the Herdr the adapter drives.
21
- */
22
- export async function namedSession(
23
- name: string,
24
- deps: RunSessionDeps,
25
- ): Promise<string | undefined> {
26
- if (OWN_SESSION.test(name)) {
27
- try {
28
- await ensureRunSession(name, deps);
29
- } catch (error) {
30
- return messageOf(error);
31
- }
32
- } else {
33
- const listed = await deps.run({
34
- argv: ["herdr", "session", "list", "--json"],
35
- timeoutMs: 10_000,
36
- });
37
- const found = parseSessions(listed.stdout).find((session) => session.name === name);
38
- if (!found?.running) {
39
- return "it is not running, and awf starts only sessions named awf-…";
40
- }
41
- }
42
- return wrongVersion(name, deps.run);
43
- }
44
-
45
15
  /** Why the session's server is not the Herdr the adapter drives; undefined when it is. */
46
16
  async function wrongVersion(session: string, run: RunProcess): Promise<string | undefined> {
47
17
  const status = await run({
@@ -154,7 +124,7 @@ export function workspaceLock(home: string) {
154
124
  await mkdir(lock);
155
125
  return () => rmdir(lock).catch(() => undefined);
156
126
  } catch (error) {
157
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
127
+ if (!isCode(error, "EEXIST")) throw error;
158
128
  }
159
129
  const made = await stat(lock).then(
160
130
  (found) => found.mtimeMs,
@@ -1,9 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomBytes } from "node:crypto";
3
- import { link, mkdir, unlink, writeFile } from "node:fs/promises";
2
+ import { mkdir } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import type { RunProcess } from "@agentswf/harness";
6
5
  import { messageOf } from "./errors";
6
+ import { linkNewText } from "./files";
7
7
  import {
8
8
  liveness,
9
9
  type MarkedPane,
@@ -53,7 +53,7 @@ const READY_POLL_MS = 200;
53
53
  const READY_WITHIN_MS = 10_000;
54
54
 
55
55
  /** Starts a server that outlives this process; settles once it has started, or could not. */
56
- export type StartServer = (
56
+ type StartServer = (
57
57
  argv: readonly string[],
58
58
  options: { cwd: string; env: Record<string, string> },
59
59
  ) => Promise<void>;
@@ -87,7 +87,7 @@ export type RunSession = {
87
87
  kept?: KeptPane[];
88
88
  };
89
89
 
90
- export type KeptPane = { run: string; session: string; paneId: string };
90
+ type KeptPane = { run: string; session: string; paneId: string };
91
91
 
92
92
  /** `AWF_HERDR_SESSION`, else `awf`; refused when Herdr could not use it as a session name. */
93
93
  export function runSessionName(environment: Readonly<Record<string, string | undefined>>): string {
@@ -431,15 +431,7 @@ export function serverEnvironment(
431
431
  async function quietConfig(home: string): Promise<string> {
432
432
  const file = join(machinePaths(home).herdr, "config.toml");
433
433
  await mkdir(dirname(file), { recursive: true });
434
- const temporary = `${file}.${randomBytes(4).toString("hex")}.tmp`;
435
- try {
436
- await writeFile(temporary, QUIET_CONFIG, { flag: "wx" });
437
- await link(temporary, file);
438
- } catch (error) {
439
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
440
- } finally {
441
- await unlink(temporary).catch(() => undefined);
442
- }
434
+ await linkNewText(file, QUIET_CONFIG);
443
435
  return file;
444
436
  }
445
437
 
@@ -1,10 +1,11 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readdir, readFile, rm } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { isCode, writeJson } from "./files";
4
5
  import { machinePaths } from "./machine";
5
6
  import { type ProcessProbe, processStart, sameProcess } from "./runs";
6
7
 
7
- export const WORKSPACE_MARK_VERSION = 2;
8
+ const WORKSPACE_MARK_VERSION = 2;
8
9
 
9
10
  /**
10
11
  * A run's claim on its workspace in a shared Herdr session: the run's process, and when that
@@ -73,7 +74,7 @@ export async function markWorkspace(
73
74
  };
74
75
  try {
75
76
  await mkdir(dir, { recursive: true });
76
- await writeWhole(file, mark);
77
+ await writeJson(file, mark);
77
78
  } catch {
78
79
  return unmarked;
79
80
  }
@@ -85,7 +86,7 @@ export async function markWorkspace(
85
86
  const write = (next: WorkspaceMark) => {
86
87
  if (released) return writing;
87
88
  current = next;
88
- writing = writing.then(() => writeWhole(file, current).catch(() => undefined));
89
+ writing = writing.then(() => writeJson(file, current).catch(() => undefined));
89
90
  return writing;
90
91
  };
91
92
  return {
@@ -110,17 +111,6 @@ export async function markWorkspace(
110
111
  };
111
112
  }
112
113
 
113
- /** Renamed into place, so a reader never sees half a mark and skips a live run. */
114
- async function writeWhole(file: string, mark: WorkspaceMark): Promise<void> {
115
- const temporary = `${file}.${randomBytes(4).toString("hex")}.tmp`;
116
- try {
117
- await writeFile(temporary, JSON.stringify(mark));
118
- await rename(temporary, file);
119
- } finally {
120
- await rm(temporary, { force: true }).catch(() => undefined);
121
- }
122
- }
123
-
124
114
  export async function readMarks(
125
115
  home: string,
126
116
  session: string,
@@ -163,7 +153,7 @@ export async function readAllMarks(
163
153
 
164
154
  /** Rewrites a mark read from `file`, as one sweep leaves it. */
165
155
  export function rewriteMark(file: string, mark: WorkspaceMark): Promise<void> {
166
- return writeWhole(file, mark).catch(() => undefined);
156
+ return writeJson(file, mark).catch(() => undefined);
167
157
  }
168
158
 
169
159
  export function removeMark(file: string): Promise<void> {
@@ -189,6 +179,6 @@ function processExists(pid: number): boolean {
189
179
  process.kill(pid, 0);
190
180
  return true;
191
181
  } catch (error) {
192
- return (error as NodeJS.ErrnoException).code !== "ESRCH";
182
+ return !isCode(error, "ESRCH");
193
183
  }
194
184
  }