Add named operator deployment commands to the CLI

42c6f1e94088 · AtlantisPleb · · parent 38625181fcd0

Add named operator deployment commands to the CLI

The openagents deploy command group consumes only the operator fleet
promotion API from OpenAgentsInc/openagents.com#57: promote states an
exact repository, full 40-character SHA, explicit environment, and a
caller-owned or CLI-generated idempotency key reused across automatic
transport retries; view and list read the status resource; --wait polls
with bounded backoff to live, failed, reverted, or needs_rolling_replace.
Terminal outcomes carry their own exit codes apart from auth, conflict,
and transport failures, a poll timeout never claims the deployment
failed, interrupting a poll touches nothing server-side and prints the
resume command, refusals for scope or standing name deployments:promote
and say forge:write cannot promote, and idempotency keys are never
printed.

Closes OpenAgentsInc/openagents#12

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>
Closes
OpenAgentsInc/openagents#12 (another repository — recorded, not closed)

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified packages/openagents-cli/README.md
  • modified packages/openagents-cli/skills/openagents-cli/SKILL.md
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/errors.ts
  • added packages/openagents-cli/src/fleet-client.ts
  • modified packages/openagents-cli/src/index.ts
  • modified packages/openagents-cli/src/main.ts
  • modified packages/openagents-cli/src/runtime.ts
  • added packages/openagents-cli/test/deploy-command.test.ts
  • modified packages/openagents-cli/test/errors.test.ts
  • added packages/openagents-cli/test/fleet-client.test.ts

Diff

11 files changed, +1472 -1

packages/openagents-cli/README.md modified +69

@@ -356,6 +356,75 @@ openagents project item-remove 2 175

356 356
Projects are repository-scoped, so every project command takes the same
357 357
`-R, --repo` and remote inference the issue commands take.
358 358
359
## Deploy the fleet (operators)
360
361
The `deploy` commands drive the operator-only fleet promotion API
362
(`/api/v3/admin/forge/targets`). They require an API token holding the
363
privileged `deployments:promote` scope, and the server additionally checks
364
that the account is a current operator on every request. `forge:write` cannot
365
promote, and neither can a Git credential or a browser session.
366
367
Sign in with the privileged scope through the same device flow. The server
368
mints the scope only for an operator account, shows it on the approval page,
369
and gives the credential a shorter lifetime than an ordinary token:
370
371
```sh
372
openagents --profile production auth login --scope deployments:promote
373
```
374
375
Promote an exact pushed commit. Every production input is explicit: the CLI
376
never resolves a branch name, never promotes the working tree, and never
377
assumes an environment. Print the value you reviewed with `git rev-parse HEAD`
378
and pass it whole:
379
380
```sh
381
openagents deploy promote \
382
  --repo openagents.com \
383
  --sha "$(git rev-parse HEAD)" \
384
  --environment production \
385
  --wait
386
```
387
388
A `202 Accepted` without `--wait` means the promotion was recorded, not that
389
production is live. `--wait` polls the status resource with bounded backoff
390
until the target reaches `live`, `failed`, `reverted`, or
391
`needs_rolling_replace`, and `--wait-timeout` bounds the watching (the target
392
keeps running when the CLI stops watching). Observe and resume at any time:
393
394
```sh
395
openagents deploy view <target-id> --wait
396
openagents deploy list --limit 10
397
```
398
399
For release automation, pass `--idempotency-key` with a key your pipeline
400
owns: retrying the same key with the same inputs returns the original target
401
instead of deploying twice, and different inputs under the same key are
402
refused with a conflict. Left out, the CLI generates a key once and reuses it
403
across its own automatic transport retries. The key is never printed. Guard
404
against racing operators with compare-and-set:
405
406
```sh
407
openagents deploy promote --repo openagents.com --sha <full-sha> \
408
  --environment production \
409
  --expected-current-target <current-target-id>
410
```
411
412
Exit codes keep the outcomes apart for scripts: authentication or operator
413
refusals exit `3`, an invalid or unknown commit exits `2`, a stale expected
414
target or idempotency conflict exits `5`, a target that reached `failed` or
415
`reverted` exits `17`, a poll that outlived `--wait-timeout` while the target
416
was still running exits `18`, and `needs_rolling_replace` exits `19`. A
417
transport failure exits `6` and never claims the deployment failed. `SIGINT`
418
stops local polling without touching the server-side target and prints the
419
`deploy view` command that resumes.
420
421
If a privileged credential leaks or an operator leaves, revoke the token in
422
the account's API token settings; the server also rechecks operator standing
423
on every request, so removing the account from the operator allowlist takes
424
effect immediately, before the token expires. During an incident, promote the
425
last known-good SHA from `deploy list` the same way — an exact SHA, an
426
explicit environment, and a fresh idempotency key.
427
359 428
Every issue and project command accepts `-R, --repo <owner>/<name>` and
360 429
otherwise infers the repository from the forge remote the way `repo view` does,
361 430
whatever that remote is named. Issue and project numbers are bare integers; a
packages/openagents-cli/skills/openagents-cli/SKILL.md modified +5

@@ -58,6 +58,11 @@ minted for `chat:account` alone cannot push, and one minted for `forge:write`

58 58
alone cannot open a thread, and each failure arrives a command later where it
59 59
reads as the product being broken.
60 60
61
One privileged scope exists beyond these: `deployments:promote`, which the
62
`openagents deploy` commands need and which the server mints only for a
63
current operator. Do not request it unless the person is an operator asking to
64
deploy the fleet; `forge:write` cannot promote.
65
61 66
Check with `openagents auth status`, which names the account, the eligible
62 67
namespaces, and the expiry without printing the token.
63 68
packages/openagents-cli/src/cli.ts modified +381

@@ -1,5 +1,6 @@

1 1
import { Clock, Console, Effect, Option, Redacted } from "effect";
2 2
import { Argument, Command, Flag } from "effect/unstable/cli";
3
import { randomUUID } from "node:crypto";
3 4
import { hostname } from "node:os";
4 5
5 6
import { apiErrorDetails, type Repository } from "./api-contract.js";

@@ -95,9 +96,13 @@ import {

95 96
  ComputerMachineUnavailable,
96 97
  ComputerPairingInProgress,
97 98
  ComputerReconnectExhausted,
99
  DeploymentFailed,
100
  DeploymentRollingReplaceRequired,
98 101
  InputError,
99 102
  NetworkRefused,
103
  type CliError,
100 104
} from "./errors.js";
105
import { FleetClient, OPERATOR_SCOPE, targetStatus, terminalStatus } from "./fleet-client.js";
101 106
import { CredentialStore } from "./credential-store.js";
102 107
import {
103 108
  PendingDeviceAuthorizationStore,

@@ -3539,6 +3544,381 @@ const projectCommand = Command.make("project").pipe(

3539 3544
3540 3545
const traceCommand = makeTraceCommand(rootCommand);
3541 3546
3547
// The deploy command group: named operator deployment commands over the
3548
// operator-only fleet promotion API from OpenAgentsInc/openagents.com#57.
3549
// It consumes only that API — never `/admin/forge`, SSH, or an internal RPC —
3550
// and it needs a token holding `deployments:promote`; `forge:write` cannot
3551
// promote. The operator states every production input explicitly: the CLI
3552
// never resolves a branch name, never promotes the working tree, and never
3553
// assumes an environment.
3554
3555
const fullShaPattern = /^[0-9a-f]{40}$/u;
3556
3557
const deployRepoFlag = Flag.string("repo").pipe(
3558
  Flag.optional,
3559
  Flag.withDescription(
3560
    "Canonical repository exactly as the server allows it, such as openagents.com",
3561
  ),
3562
);
3563
const deployShaFlag = Flag.string("sha").pipe(
3564
  Flag.optional,
3565
  Flag.withDescription("Full 40-character commit SHA; branch names and abbreviations are refused"),
3566
);
3567
const deployEnvironmentFlag = Flag.string("environment").pipe(
3568
  Flag.optional,
3569
  Flag.withDescription("Deployment environment, stated explicitly; the server admits production"),
3570
);
3571
const deployIdempotencyKeyFlag = Flag.string("idempotency-key").pipe(
3572
  Flag.optional,
3573
  Flag.withDescription(
3574
    "Caller-generated idempotency key for controlled automation; omitted, the CLI generates one and reuses it across automatic retries. Never printed",
3575
  ),
3576
);
3577
const deployExpectedTargetFlag = Flag.string("expected-current-target").pipe(
3578
  Flag.optional,
3579
  Flag.withDescription(
3580
    "Compare-and-set: refuse the promotion when the current target is no longer this ID",
3581
  ),
3582
);
3583
const deployWaitFlag = Flag.boolean("wait").pipe(
3584
  Flag.withDescription(
3585
    "Poll the status resource with bounded backoff until the target reaches live, failed, reverted, or needs_rolling_replace",
3586
  ),
3587
);
3588
const deployWaitTimeoutFlag = Flag.integer("wait-timeout").pipe(
3589
  Flag.withDefault(600),
3590
  Flag.withDescription(
3591
    "Seconds --wait polls before reporting a timeout (the target keeps running)",
3592
  ),
3593
);
3594
const deployListLimitFlag = Flag.integer("limit").pipe(
3595
  Flag.withDefault(10),
3596
  Flag.withDescription("Return between 1 and 50 recent targets"),
3597
);
3598
const deployTargetArgument = Argument.string("target-id").pipe(
3599
  Argument.withDescription("Fleet target ID returned by deploy promote or deploy list"),
3600
);
3601
3602
/**
3603
 * An operator refused for standing or scope needs the exact next command, not
3604
 * a bare status. The remediation names the privileged scope and says plainly
3605
 * that `forge:write` is not it.
3606
 */
3607
const operatorRemediation = (error: CliError): CliError =>
3608
  error._tag === "OpenAgentsCli.ApiError" && (error.status === 401 || error.status === 403)
3609
    ? new ApiError({
3610
        operation: error.operation,
3611
        status: error.status,
3612
        ...(error.code === undefined ? {} : { code: error.code }),
3613
        message:
3614
          `${error.message} Fleet promotion requires an operator API token holding ` +
3615
          `${OPERATOR_SCOPE}; forge:write cannot promote, and neither can a Git credential ` +
3616
          `or a browser session. An operator obtains one with: ` +
3617
          `openagents auth login --scope ${OPERATOR_SCOPE}`,
3618
        ...(error.requestId === undefined ? {} : { requestId: error.requestId }),
3619
      })
3620
    : error;
3621
3622
const fleetTargetId = (target: Record<string, unknown>): string => String(target["id"] ?? "");
3623
3624
// Environment, repository, full SHA, target ID, state, and status URL come
3625
// before any success wording, so the operator reads what was promoted before
3626
// reading how it went.
3627
const fleetTargetHuman = (target: Record<string, unknown>): ReadonlyArray<string> => [
3628
  `Environment: ${String(target["environment"] ?? "")}`,
3629
  `Repository:  ${String(target["repo"] ?? "")}`,
3630
  `SHA:         ${String(target["sha"] ?? "")}`,
3631
  `Target:      ${fleetTargetId(target)}`,
3632
  `State:       ${targetStatus(target)}`,
3633
  `Status URL:  ${String(target["status_url"] ?? "")}`,
3634
];
3635
3636
const fleetFailureCode = (target: Record<string, unknown>): string | null => {
3637
  const code = target["error_code"];
3638
  return typeof code === "string" ? code : null;
3639
};
3640
3641
const fleetOutcome = (target: Record<string, unknown>, nonterminal: string): string => {
3642
  const status = targetStatus(target);
3643
  return terminalStatus(status) ? status : nonterminal;
3644
};
3645
3646
const fleetTargetDocument = (
3647
  schema: string,
3648
  target: Record<string, unknown>,
3649
  nonterminal: string,
3650
  extra: Record<string, unknown> = {},
3651
): Record<string, unknown> => ({
3652
  schema,
3653
  ...extra,
3654
  outcome: fleetOutcome(target, nonterminal),
3655
  live: targetStatus(target) === "live",
3656
  terminal: terminalStatus(targetStatus(target)),
3657
  failure_code: fleetFailureCode(target),
3658
  target,
3659
});
3660
3661
const fleetTerminalHuman = (target: Record<string, unknown>): string => {
3662
  switch (targetStatus(target)) {
3663
    case "live":
3664
      return "The fleet target is live.";
3665
    case "needs_rolling_replace":
3666
      return "The target needs an operator-driven rolling replacement; the automatic lanes stopped.";
3667
    default:
3668
      return `The fleet target reached ${targetStatus(target)}.`;
3669
  }
3670
};
3671
3672
/**
3673
 * Turns a terminal target into the command's exit behavior after the full
3674
 * document is already written: `failed` and `reverted` are a deployment
3675
 * failure, `needs_rolling_replace` is its own condition, `live` succeeds.
3676
 */
3677
const concludeFleetTarget = Effect.fn("Cli.concludeFleetTarget")(function* (
3678
  target: Record<string, unknown>,
3679
) {
3680
  const status = targetStatus(target);
3681
  const id = fleetTargetId(target);
3682
  if (status === "failed" || status === "reverted") {
3683
    const code = fleetFailureCode(target);
3684
    return yield* new DeploymentFailed({
3685
      targetId: id,
3686
      status,
3687
      ...(code === null ? {} : { code }),
3688
      message: `The fleet target ${id} reached ${status}${code === null ? "" : ` (${code})`}.`,
3689
    });
3690
  }
3691
  if (status === "needs_rolling_replace") {
3692
    return yield* new DeploymentRollingReplaceRequired({
3693
      targetId: id,
3694
      message: `The fleet target ${id} needs a rolling replacement before it can be live.`,
3695
    });
3696
  }
3697
});
3698
3699
// Interrupting the poll stops only the CLI's watching. No cancellation is
3700
// sent — the API defines none — and the hint names the target so the
3701
// operator resumes instead of guessing.
3702
const watchFleetTarget = (
3703
  session: {
3704
    readonly endpoint: { readonly origin: string };
3705
    readonly token: Redacted.Redacted<string>;
3706
  },
3707
  id: string,
3708
  waitTimeoutSeconds: number,
3709
) =>
3710
  Effect.gen(function* () {
3711
    const fleet = yield* FleetClient;
3712
    return yield* fleet
3713
      .wait({
3714
        origin: session.endpoint.origin,
3715
        token: session.token,
3716
        id,
3717
        timeoutMs: waitTimeoutSeconds * 1_000,
3718
      })
3719
      .pipe(
3720
        Effect.mapError(operatorRemediation),
3721
        Effect.onInterrupt(() =>
3722
          Console.error(
3723
            `Polling stopped; the server-side target is untouched. ` +
3724
              `Resume with: openagents deploy view ${id} --wait`,
3725
          ).pipe(Effect.ignore),
3726
        ),
3727
      );
3728
  });
3729
3730
const deployPromoteCommand = Command.make(
3731
  "promote",
3732
  {
3733
    repo: deployRepoFlag,
3734
    sha: deployShaFlag,
3735
    environment: deployEnvironmentFlag,
3736
    idempotencyKey: deployIdempotencyKeyFlag,
3737
    expectedCurrentTarget: deployExpectedTargetFlag,
3738
    wait: deployWaitFlag,
3739
    waitTimeout: deployWaitTimeoutFlag,
3740
  },
3741
  ({ environment, expectedCurrentTarget, idempotencyKey, repo, sha, wait, waitTimeout }) =>
3742
    Effect.gen(function* () {
3743
      if (Option.isNone(repo)) {
3744
        return yield* new InputError({
3745
          message:
3746
            "Pass --repo with the canonical repository the server deploys, such as --repo openagents.com.",
3747
        });
3748
      }
3749
      if (Option.isNone(sha)) {
3750
        return yield* new InputError({
3751
          message: "Pass --sha with the full 40-character commit SHA you reviewed.",
3752
        });
3753
      }
3754
      const shaValue = sha.value.trim().toLowerCase();
3755
      if (!fullShaPattern.test(shaValue)) {
3756
        return yield* new InputError({
3757
          message:
3758
            "--sha must be one full 40-character commit SHA. Branch names, tags, and " +
3759
            "abbreviations are refused; print the exact reviewed value with: git rev-parse HEAD",
3760
        });
3761
      }
3762
      if (Option.isNone(environment)) {
3763
        return yield* new InputError({
3764
          message:
3765
            "Pass --environment production explicitly. Production promotion never assumes an environment.",
3766
        });
3767
      }
3768
      if (waitTimeout < 1) {
3769
        return yield* new InputError({ message: "--wait-timeout must be at least 1 second." });
3770
      }
3771
      const flags = yield* rootCommand;
3772
      const session = yield* resolveApiSession(endpointOverrides(flags));
3773
      const fleet = yield* FleetClient;
3774
      const output = yield* Output;
3775
      // Generated once and reused across automatic transport retries; passed
3776
      // through --idempotency-key for controlled automation. Never printed.
3777
      const key = Option.isSome(idempotencyKey) ? idempotencyKey.value : randomUUID();
3778
      const result = yield* fleet
3779
        .promote({
3780
          origin: session.endpoint.origin,
3781
          token: session.token,
3782
          repo: repo.value,
3783
          sha: shaValue,
3784
          environment: environment.value,
3785
          idempotencyKey: key,
3786
          ...(Option.isNone(expectedCurrentTarget)
3787
            ? {}
3788
            : { expectedCurrentTargetId: expectedCurrentTarget.value }),
3789
        })
3790
        .pipe(Effect.mapError(operatorRemediation));
3791
      const id = fleetTargetId(result.target);
3792
      if (!wait) {
3793
        // 202 means the promotion was recorded, not that production is live.
3794
        yield* output.write(
3795
          {
3796
            value: fleetTargetDocument("openagents.fleet_promotion.v1", result.target, "accepted", {
3797
              accepted: true,
3798
              replayed: result.replayed,
3799
            }),
3800
            human: [
3801
              ...fleetTargetHuman(result.target),
3802
              result.replayed
3803
                ? "This idempotency key already named this promotion; the original target is returned."
3804
                : "Promotion accepted. Accepted means recorded, not live; the fleet deploys it now.",
3805
              `Follow it with: openagents deploy view ${id} --wait`,
3806
            ],
3807
          },
3808
          outputMode(flags.json),
3809
        );
3810
        return;
3811
      }
3812
      const finalTarget = yield* watchFleetTarget(session, id, waitTimeout);
3813
      yield* output.write(
3814
        {
3815
          value: fleetTargetDocument("openagents.fleet_promotion.v1", finalTarget, "accepted", {
3816
            accepted: true,
3817
            replayed: result.replayed,
3818
          }),
3819
          human: [...fleetTargetHuman(finalTarget), fleetTerminalHuman(finalTarget)],
3820
        },
3821
        outputMode(flags.json),
3822
      );
3823
      yield* concludeFleetTarget(finalTarget);
3824
    }),
3825
).pipe(
3826
  Command.withDescription(
3827
    "Promote an exact pushed commit as the production fleet target (operator only)",
3828
  ),
3829
);
3830
3831
const deployViewCommand = Command.make(
3832
  "view",
3833
  { target: deployTargetArgument, wait: deployWaitFlag, waitTimeout: deployWaitTimeoutFlag },
3834
  ({ target, wait, waitTimeout }) =>
3835
    Effect.gen(function* () {
3836
      if (waitTimeout < 1) {
3837
        return yield* new InputError({ message: "--wait-timeout must be at least 1 second." });
3838
      }
3839
      const flags = yield* rootCommand;
3840
      const session = yield* resolveApiSession(endpointOverrides(flags));
3841
      const fleet = yield* FleetClient;
3842
      const output = yield* Output;
3843
      if (!wait) {
3844
        // A bare view is a read: it reports the state and exits zero even for
3845
        // a failed target. Exit behavior for terminal states belongs to --wait.
3846
        const value = yield* fleet
3847
          .view({ origin: session.endpoint.origin, token: session.token, id: target })
3848
          .pipe(Effect.mapError(operatorRemediation));
3849
        yield* output.write(
3850
          {
3851
            value: fleetTargetDocument("openagents.fleet_target.v1", value, "pending"),
3852
            human: fleetTargetHuman(value),
3853
          },
3854
          outputMode(flags.json),
3855
        );
3856
        return;
3857
      }
3858
      const finalTarget = yield* watchFleetTarget(session, target, waitTimeout);
3859
      yield* output.write(
3860
        {
3861
          value: fleetTargetDocument("openagents.fleet_target.v1", finalTarget, "pending"),
3862
          human: [...fleetTargetHuman(finalTarget), fleetTerminalHuman(finalTarget)],
3863
        },
3864
        outputMode(flags.json),
3865
      );
3866
      yield* concludeFleetTarget(finalTarget);
3867
    }),
3868
).pipe(Command.withDescription("Show one fleet target; --wait follows it to a terminal state"));
3869
3870
const fleetTargetRow = (target: Record<string, unknown>): string =>
3871
  [
3872
    fleetTargetId(target).padEnd(38),
3873
    targetStatus(target).padEnd(22),
3874
    String(target["sha"] ?? ""),
3875
    `  ${String(target["promoted_at"] ?? "")}`,
3876
  ].join("");
3877
3878
const deployListCommand = Command.make(
3879
  "list",
3880
  { repo: deployRepoFlag, limit: deployListLimitFlag },
3881
  ({ limit, repo }) =>
3882
    Effect.gen(function* () {
3883
      if (limit < 1 || limit > 50) {
3884
        return yield* new InputError({ message: "--limit must be between 1 and 50." });
3885
      }
3886
      const flags = yield* rootCommand;
3887
      const session = yield* resolveApiSession(endpointOverrides(flags));
3888
      const fleet = yield* FleetClient;
3889
      const output = yield* Output;
3890
      const value = yield* fleet
3891
        .list({
3892
          origin: session.endpoint.origin,
3893
          token: session.token,
3894
          limit,
3895
          ...(Option.isNone(repo) ? {} : { repo: repo.value }),
3896
        })
3897
        .pipe(Effect.mapError(operatorRemediation));
3898
      const targets = rows(value, "targets");
3899
      yield* output.write(
3900
        {
3901
          value,
3902
          human:
3903
            targets.length === 0
3904
              ? ["No fleet targets found."]
3905
              : [
3906
                  `Repository: ${String(record(value)["repo"] ?? "")}`,
3907
                  ...targets.map(fleetTargetRow),
3908
                ],
3909
        },
3910
        outputMode(flags.json),
3911
      );
3912
    }),
3913
).pipe(Command.withDescription("List recent fleet targets, newest first"));
3914
3915
const deployCommand = Command.make("deploy").pipe(
3916
  Command.withDescription(
3917
    `Operator deployment of the OpenAgents fleet. Requires an operator token holding ${OPERATOR_SCOPE}; forge:write cannot promote`,
3918
  ),
3919
  Command.withSubcommands([deployPromoteCommand, deployViewCommand, deployListCommand]),
3920
);
3921
3542 3922
export const openagentsCommand = rootCommand.pipe(
3543 3923
  Command.withSubcommands([
3544 3924
    apiCommand,

@@ -3546,6 +3926,7 @@ export const openagentsCommand = rootCommand.pipe(

3546 3926
    coderCommand,
3547 3927
    delegateCommand,
3548 3928
    computerCommand,
3929
    deployCommand,
3549 3930
    forumCommand,
3550 3931
    issueCommand,
3551 3932
    projectCommand,
packages/openagents-cli/src/errors.ts modified +47 -1

@@ -176,6 +176,40 @@ export class TraceUploadUnsupported extends Schema.TaggedErrorClass<TraceUploadU

176 176
  { message: Schema.String },
177 177
) {}
178 178
179
/** A fleet promotion target reached `failed` or `reverted`. */
180
export class DeploymentFailed extends Schema.TaggedErrorClass<DeploymentFailed>()(
181
  "OpenAgentsCli.DeploymentFailed",
182
  {
183
    targetId: Schema.String,
184
    status: Schema.String,
185
    code: Schema.optionalKey(Schema.String),
186
    message: Schema.String,
187
  },
188
) {}
189
190
/**
191
 * Polling ended while the fleet target was still nonterminal. The target
192
 * itself has not failed; the CLI simply stopped watching.
193
 */
194
export class DeploymentWaitTimeout extends Schema.TaggedErrorClass<DeploymentWaitTimeout>()(
195
  "OpenAgentsCli.DeploymentWaitTimeout",
196
  {
197
    targetId: Schema.String,
198
    timeoutMs: Schema.Number,
199
    lastStatus: Schema.String,
200
    message: Schema.String,
201
  },
202
) {}
203
204
/** The target needs an operator-driven rolling replacement to finish. */
205
export class DeploymentRollingReplaceRequired extends Schema.TaggedErrorClass<DeploymentRollingReplaceRequired>()(
206
  "OpenAgentsCli.DeploymentRollingReplaceRequired",
207
  {
208
    targetId: Schema.String,
209
    message: Schema.String,
210
  },
211
) {}
212
179 213
export type CliError =
180 214
  | InputError
181 215
  | ConfigurationError

@@ -202,7 +236,10 @@ export type CliError =

202 236
  | ComputerMachineUnavailable
203 237
  | ComputerMachineMismatch
204 238
  | ComputerReconnectExhausted
205
  | TraceUploadUnsupported;
239
  | TraceUploadUnsupported
240
  | DeploymentFailed
241
  | DeploymentWaitTimeout
242
  | DeploymentRollingReplaceRequired;
206 243
207 244
export const exitCodeFor = (error: CliError): number => {
208 245
  switch (error._tag) {

@@ -230,6 +267,15 @@ export const exitCodeFor = (error: CliError): number => {

230 267
      return 15;
231 268
    case "OpenAgentsCli.TraceUploadUnsupported":
232 269
      return 16;
270
    // Deployment outcomes stay apart from each other and from transport
271
    // failures, so release automation can tell "the fleet rejected these
272
    // bytes" from "the CLI stopped watching" without parsing prose.
273
    case "OpenAgentsCli.DeploymentFailed":
274
      return 17;
275
    case "OpenAgentsCli.DeploymentWaitTimeout":
276
      return 18;
277
    case "OpenAgentsCli.DeploymentRollingReplaceRequired":
278
      return 19;
233 279
    case "OpenAgentsCli.AuthenticationRequired":
234 280
    case "OpenAgentsCli.CredentialPersistenceUnavailable":
235 281
    case "OpenAgentsCli.CredentialStoreError":
packages/openagents-cli/src/fleet-client.ts added +220

@@ -0,0 +1,220 @@

1
/**
2
 * The operator fleet promotion client.
3
 *
4
 * It speaks only the operator API from OpenAgentsInc/openagents.com#57 —
5
 * `POST/GET /api/v3/admin/forge/targets` — behind the same `/api/v3` error
6
 * envelope every other command family reads. It never touches `/admin/forge`,
7
 * SSH, or any internal RPC, and it adds only what a terminal caller cannot do
8
 * for itself: an idempotent re-send after a failed transport, and bounded
9
 * polling of the status resource to a terminal state.
10
 */
11
12
import { Clock, Duration, Effect, Layer } from "effect";
13
import * as Context from "effect/Context";
14
15
import { ApiTransport } from "./api-transport.js";
16
import { ApiError, DeploymentWaitTimeout, type CliError } from "./errors.js";
17
import type { AuthenticatedApi } from "./repository-client.js";
18
import { asRecord, asText, makeTrackerRequest, trackerErrorDetails } from "./tracker-request.js";
19
20
/** The one route family from OpenAgentsInc/openagents.com#57. */
21
export const FLEET_TARGETS_PATH = "/api/v3/admin/forge/targets";
22
23
/** The privileged scope the server requires; `forge:write` cannot promote. */
24
export const OPERATOR_SCOPE = "deployments:promote";
25
26
/**
27
 * The states polling stops on. The server marks `live`, `failed`, and
28
 * `reverted` terminal; `needs_rolling_replace` additionally ends automatic
29
 * execution and waits on an operator, so a poll that reached it would
30
 * otherwise never return.
31
 */
32
export const TERMINAL_STATES: ReadonlyArray<string> = [
33
  "live",
34
  "failed",
35
  "reverted",
36
  "needs_rolling_replace",
37
];
38
39
/** How many times a promotion is re-sent after a failed transport. */
40
export const PROMOTE_TRANSPORT_RETRIES = 2;
41
42
const retryDelayMs = 500;
43
44
/** Bounded backoff: 2s, 4s, 8s, then every 10s until the deadline. */
45
export const POLL_BASE_DELAY_MS = 2_000;
46
export const POLL_MAXIMUM_DELAY_MS = 10_000;
47
48
export interface FleetPromoteInput extends AuthenticatedApi {
49
  readonly repo: string;
50
  readonly sha: string;
51
  readonly environment: string;
52
  /** Generated once by the caller and reused across automatic retries. */
53
  readonly idempotencyKey: string;
54
  readonly expectedCurrentTargetId?: string;
55
}
56
57
export interface FleetPromoteResult {
58
  /** True when the server answered `202 Accepted` with a new target. */
59
  readonly accepted: boolean;
60
  /** True when the idempotency key replayed an existing identical promotion. */
61
  readonly replayed: boolean;
62
  readonly target: Record<string, unknown>;
63
}
64
65
export interface FleetTargetInput extends AuthenticatedApi {
66
  readonly id: string;
67
}
68
69
export interface FleetWaitInput extends FleetTargetInput {
70
  readonly timeoutMs: number;
71
  readonly baseDelayMs?: number;
72
  readonly maximumDelayMs?: number;
73
}
74
75
export interface FleetListInput extends AuthenticatedApi {
76
  readonly repo?: string;
77
  readonly limit?: number;
78
}
79
80
interface FleetClientInterface {
81
  readonly promote: (input: FleetPromoteInput) => Effect.Effect<FleetPromoteResult, CliError>;
82
  readonly view: (input: FleetTargetInput) => Effect.Effect<Record<string, unknown>, CliError>;
83
  readonly list: (input: FleetListInput) => Effect.Effect<unknown, CliError>;
84
  readonly wait: (input: FleetWaitInput) => Effect.Effect<Record<string, unknown>, CliError>;
85
}
86
87
export class FleetClient extends Context.Service<FleetClient, FleetClientInterface>()(
88
  "@openagentsinc/cli/FleetClient",
89
) {}
90
91
/** Reads the lifecycle state off a target body. */
92
export const targetStatus = (target: Record<string, unknown>): string =>
93
  asText(target["status"]) ?? "unknown";
94
95
/** Whether polling has nothing further to learn about this target. */
96
export const terminalStatus = (status: string): boolean => TERMINAL_STATES.includes(status);
97
98
export const fleetClientLayer = Layer.effect(
99
  FleetClient,
100
  Effect.gen(function* () {
101
    const transport = yield* ApiTransport;
102
    const request = makeTrackerRequest(transport);
103
104
    const view = Effect.fn("FleetClient.view")(function* (input: FleetTargetInput) {
105
      const body = yield* request("read a fleet target", {
106
        origin: input.origin,
107
        token: input.token,
108
        method: "GET",
109
        path: `${FLEET_TARGETS_PATH}/${encodeURIComponent(input.id)}`,
110
        acceptedStatuses: [200],
111
      });
112
      return asRecord(body);
113
    });
114
115
    // `202` admits a new target; `200` replays the identical promotion the
116
    // same key already named. The distinction is the answer to "did I just
117
    // deploy, or had I already?", so the status must survive translation and
118
    // the shared accepted-status helper cannot carry it.
119
    const promoteAttempt = Effect.fn("FleetClient.promoteAttempt")(function* (
120
      input: FleetPromoteInput,
121
    ) {
122
      const response = yield* transport.request({
123
        origin: input.origin,
124
        token: input.token,
125
        method: "POST",
126
        path: FLEET_TARGETS_PATH,
127
        body: {
128
          repo: input.repo,
129
          sha: input.sha,
130
          environment: input.environment,
131
          idempotency_key: input.idempotencyKey,
132
          ...(input.expectedCurrentTargetId === undefined
133
            ? {}
134
            : { expected_current_target_id: input.expectedCurrentTargetId }),
135
        },
136
      });
137
      if (response.status !== 202 && response.status !== 200) {
138
        const details = trackerErrorDetails(response.body, response.status);
139
        return yield* new ApiError({
140
          operation: "promote a fleet target",
141
          status: response.status,
142
          ...(details.code === undefined ? {} : { code: details.code }),
143
          message: details.message,
144
          ...(response.requestId === undefined && details.requestId === undefined
145
            ? {}
146
            : { requestId: response.requestId ?? details.requestId }),
147
        });
148
      }
149
      return {
150
        accepted: response.status === 202,
151
        replayed: response.status === 200,
152
        target: asRecord(response.body),
153
      } satisfies FleetPromoteResult;
154
    });
155
156
    // The idempotency key travels in the body, so every attempt names the
157
    // same promotion and a re-send can never deploy twice. Only a failed
158
    // transport is retried — the request may never have reached the server;
159
    // a refusal the server actually made is final.
160
    const promote = Effect.fn("FleetClient.promote")(function* (input: FleetPromoteInput) {
161
      let attempt = 0;
162
      while (true) {
163
        const outcome = yield* promoteAttempt(input).pipe(
164
          Effect.map((value) => ({ ok: true, value }) as const),
165
          Effect.catchTag("OpenAgentsCli.TransportError", (failure) =>
166
            Effect.succeed({ ok: false, failure } as const),
167
          ),
168
        );
169
        if (outcome.ok) return outcome.value;
170
        if (attempt >= PROMOTE_TRANSPORT_RETRIES) return yield* outcome.failure;
171
        attempt += 1;
172
        yield* Effect.sleep(Duration.millis(retryDelayMs * attempt));
173
      }
174
    });
175
176
    const list = (input: FleetListInput) => {
177
      const parameters = new URLSearchParams();
178
      if (input.repo !== undefined) parameters.set("repo", input.repo);
179
      if (input.limit !== undefined) parameters.set("limit", String(input.limit));
180
      const query = parameters.toString();
181
      return request("list fleet targets", {
182
        origin: input.origin,
183
        token: input.token,
184
        method: "GET",
185
        path: query === "" ? FLEET_TARGETS_PATH : `${FLEET_TARGETS_PATH}?${query}`,
186
        acceptedStatuses: [200],
187
      });
188
    };
189
190
    const wait = Effect.fn("FleetClient.wait")(function* (input: FleetWaitInput) {
191
      const baseDelayMs = input.baseDelayMs ?? POLL_BASE_DELAY_MS;
192
      const maximumDelayMs = input.maximumDelayMs ?? POLL_MAXIMUM_DELAY_MS;
193
      const startedAt = yield* Clock.currentTimeMillis;
194
      let attempt = 0;
195
      while (true) {
196
        const target = yield* view(input);
197
        const status = targetStatus(target);
198
        if (terminalStatus(status)) return target;
199
        const now = yield* Clock.currentTimeMillis;
200
        if (now - startedAt >= input.timeoutMs) {
201
          // The target is still running; only the CLI's watching ended.
202
          return yield* new DeploymentWaitTimeout({
203
            targetId: input.id,
204
            timeoutMs: input.timeoutMs,
205
            lastStatus: status,
206
            message:
207
              `The fleet target ${input.id} was still ${status} after ` +
208
              `${Math.round(input.timeoutMs / 1_000)}s. The deployment has not failed; ` +
209
              `resume with: openagents deploy view ${input.id} --wait`,
210
          });
211
        }
212
        const delay = Math.min(baseDelayMs * 2 ** attempt, maximumDelayMs);
213
        attempt += 1;
214
        yield* Effect.sleep(Duration.millis(delay));
215
      }
216
    });
217
218
    return FleetClient.of({ promote, view, list, wait });
219
  }),
220
);
packages/openagents-cli/src/index.ts modified +1

@@ -12,6 +12,7 @@ export * from "./device-authorization-store.js";

12 12
export * from "./endpoint.js";
13 13
export * from "./environment.js";
14 14
export * from "./errors.js";
15
export * from "./fleet-client.js";
15 16
export * from "./git-runner.js";
16 17
export * from "./issue-client.js";
17 18
export * from "./output.js";
packages/openagents-cli/src/main.ts modified +3

@@ -35,6 +35,9 @@ const cliErrorTags = new Set([

35 35
  "OpenAgentsCli.ComputerMachineMismatch",
36 36
  "OpenAgentsCli.ComputerReconnectExhausted",
37 37
  "OpenAgentsCli.TraceUploadUnsupported",
38
  "OpenAgentsCli.DeploymentFailed",
39
  "OpenAgentsCli.DeploymentWaitTimeout",
40
  "OpenAgentsCli.DeploymentRollingReplaceRequired",
38 41
]);
39 42
40 43
const isCliError = (value: unknown): value is CliError =>
packages/openagents-cli/src/runtime.ts modified +3

@@ -15,6 +15,7 @@ import { credentialStoreOsLayer } from "./credential-store.js";

15 15
import { pendingDeviceAuthorizationStoreLayer } from "./device-authorization-store.js";
16 16
import { deviceClientLayer } from "./device-client.js";
17 17
import { environmentLayer } from "./environment.js";
18
import { fleetClientLayer } from "./fleet-client.js";
18 19
import { forumClientLayer } from "./forum-client.js";
19 20
import { gitRunnerLayer } from "./git-runner.js";
20 21
import { issueClientLayer } from "./issue-client.js";

@@ -32,6 +33,7 @@ const transportLayer = apiTransportNodeLayer.pipe(

32 33
33 34
const repositoryLayer = repositoryClientLayer.pipe(Layer.provide(transportLayer));
34 35
const forumLayer = forumClientLayer.pipe(Layer.provide(transportLayer));
36
const fleetLayer = fleetClientLayer.pipe(Layer.provide(transportLayer));
35 37
const deviceLayer = deviceClientLayer.pipe(Layer.provide(transportLayer));
36 38
const issueLayer = issueClientLayer.pipe(Layer.provide(transportLayer));
37 39
const projectLayer = projectClientLayer.pipe(Layer.provide(transportLayer));

@@ -79,6 +81,7 @@ export const runtimeLayer = Layer.mergeAll(

79 81
  pendingAuthorizationLayer,
80 82
  repositoryLayer,
81 83
  forumLayer,
84
  fleetLayer,
82 85
  issueLayer,
83 86
  projectLayer,
84 87
  deviceLayer,
packages/openagents-cli/test/deploy-command.test.ts added +415

@@ -0,0 +1,415 @@

1
import * as NodeServices from "@effect/platform-node/NodeServices";
2
import { Effect, Fiber, Layer } from "effect";
3
import { describe, expect, it, vi } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { runCliWith } from "../src/cli.js";
7
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
8
import { environmentLayerFromValues } from "../src/environment.js";
9
import { fleetClientLayer } from "../src/fleet-client.js";
10
import { gitRunnerTestLayer } from "../src/git-runner.js";
11
import { issueClientLayer } from "../src/issue-client.js";
12
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
13
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
14
import { projectClientLayer } from "../src/project-client.js";
15
import { requestBodyInputTestLayer } from "../src/request-body-input.js";
16
import { secretInputTestLayer } from "../src/secret-input.js";
17
import { terminalSessionTestLayer } from "../src/terminal-session.js";
18
19
interface Written {
20
  readonly document: OutputDocument;
21
  readonly mode: OutputMode;
22
}
23
24
const fullSha = "f".repeat(40);
25
const targetId = "0d4e8a70-0000-4000-8000-000000000001";
26
27
const targetBody = (status: string, overrides: Record<string, unknown> = {}) => ({
28
  id: targetId,
29
  repo: "openagents.com",
30
  sha: fullSha,
31
  status,
32
  terminal: ["live", "failed", "reverted"].includes(status),
33
  promoted_by: "operator:1",
34
  environment: "production",
35
  source: "api",
36
  artifact_digest: null,
37
  deployment_lane: null,
38
  error_code: null,
39
  promoted_at: "2026-08-24T00:00:00Z",
40
  updated_at: "2026-08-24T00:00:00Z",
41
  status_url: `http://localhost:4000/api/v3/admin/forge/targets/${targetId}`,
42
  ...overrides,
43
});
44
45
const envelope = (status: number, code: string, message: string) => ({
46
  status,
47
  body: { message, code, status, documentation_url: "", request_id: "req-1", errors: {} },
48
});
49
50
const harness = (handler: (input: ApiRequest) => Effect.Effect<ApiResponse, never>) => {
51
  const written: Array<Written> = [];
52
  const transport = apiTransportTestLayer(handler);
53
  const layer = Layer.mergeAll(
54
    NodeServices.layer,
55
    environmentLayerFromValues({ token: "test-token" }),
56
    persistedConfigurationTestLayer({}),
57
    terminalSessionTestLayer(false),
58
    credentialStoreUnavailableLayer,
59
    gitRunnerTestLayer(() => Effect.void),
60
    secretInputTestLayer("stdin-token"),
61
    requestBodyInputTestLayer({}),
62
    fleetClientLayer.pipe(Layer.provide(transport)),
63
    issueClientLayer.pipe(Layer.provide(transport)),
64
    projectClientLayer.pipe(Layer.provide(transport)),
65
    outputTestLayer((document, mode) =>
66
      Effect.sync(() => {
67
        written.push({ document, mode });
68
      }),
69
    ),
70
  );
71
  const program = (argv: ReadonlyArray<string>) =>
72
    runCliWith(["--profile", "local", ...argv]).pipe(Effect.provide(layer)) as Effect.Effect<
73
      void,
74
      unknown
75
    >;
76
  const run = (argv: ReadonlyArray<string>) => Effect.runPromise(program(argv));
77
  return { run, program, written };
78
};
79
80
const promoteArgv = (extra: ReadonlyArray<string> = []) => [
81
  "deploy",
82
  "promote",
83
  "--repo",
84
  "openagents.com",
85
  "--sha",
86
  fullSha,
87
  "--environment",
88
  "production",
89
  ...extra,
90
];
91
92
describe("deploy commands", () => {
93
  it("promotes the exact reviewed inputs and reports accepted, not live", async () => {
94
    const requests: Array<ApiRequest> = [];
95
    const { run, written } = harness((input) =>
96
      Effect.sync(() => {
97
        requests.push(input);
98
        return { status: 202, body: targetBody("queued") };
99
      }),
100
    );
101
102
    await run([
103
      "--json",
104
      ...promoteArgv([
105
        "--idempotency-key",
106
        "release-key-0001",
107
        "--expected-current-target",
108
        "0d4e8a70-0000-4000-8000-000000000000",
109
      ]),
110
    ]);
111
112
    expect(requests).toHaveLength(1);
113
    expect(requests[0]?.method).toBe("POST");
114
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets");
115
    expect(requests[0]?.body).toEqual({
116
      repo: "openagents.com",
117
      sha: fullSha,
118
      environment: "production",
119
      idempotency_key: "release-key-0001",
120
      expected_current_target_id: "0d4e8a70-0000-4000-8000-000000000000",
121
    });
122
    const value = written[0]?.document.value as Record<string, unknown>;
123
    expect(written[0]?.mode).toBe("json");
124
    expect(value["accepted"]).toBe(true);
125
    expect(value["live"]).toBe(false);
126
    expect(value["outcome"]).toBe("accepted");
127
    expect(value["replayed"]).toBe(false);
128
    expect((value["target"] as Record<string, unknown>)["sha"]).toBe(fullSha);
129
  });
130
131
  it("never prints the idempotency key in output or the human summary", async () => {
132
    const { run, written } = harness(() =>
133
      Effect.succeed({ status: 202, body: targetBody("queued") }),
134
    );
135
136
    await run(["--json", ...promoteArgv(["--idempotency-key", "secret-idempotency-key"])]);
137
138
    expect(JSON.stringify(written)).not.toContain("secret-idempotency-key");
139
  });
140
141
  it("generates one idempotency key and reuses it when it must not be printed", async () => {
142
    const requests: Array<ApiRequest> = [];
143
    const { run, written } = harness((input) =>
144
      Effect.sync(() => {
145
        requests.push(input);
146
        return { status: 202, body: targetBody("queued") };
147
      }),
148
    );
149
150
    await run(promoteArgv());
151
152
    const key = (requests[0]?.body as Record<string, unknown>)["idempotency_key"];
153
    expect(typeof key).toBe("string");
154
    expect((key as string).length).toBeGreaterThanOrEqual(8);
155
    expect(JSON.stringify(written)).not.toContain(key);
156
  });
157
158
  it("refuses an abbreviated SHA before sending any request", async () => {
159
    const requests: Array<ApiRequest> = [];
160
    const { run } = harness((input) =>
161
      Effect.sync(() => {
162
        requests.push(input);
163
        return { status: 202, body: targetBody("queued") };
164
      }),
165
    );
166
167
    await expect(
168
      run([
169
        "deploy",
170
        "promote",
171
        "--repo",
172
        "openagents.com",
173
        "--sha",
174
        "f".repeat(12),
175
        "--environment",
176
        "production",
177
      ]),
178
    ).rejects.toThrow(/full 40-character commit SHA/u);
179
    expect(requests).toHaveLength(0);
180
  });
181
182
  it("refuses a branch name in --sha before sending any request", async () => {
183
    const requests: Array<ApiRequest> = [];
184
    const { run } = harness((input) =>
185
      Effect.sync(() => {
186
        requests.push(input);
187
        return { status: 202, body: targetBody("queued") };
188
      }),
189
    );
190
191
    await expect(
192
      run([
193
        "deploy",
194
        "promote",
195
        "--repo",
196
        "openagents.com",
197
        "--sha",
198
        "main",
199
        "--environment",
200
        "production",
201
      ]),
202
    ).rejects.toThrow(/full 40-character commit SHA/u);
203
    expect(requests).toHaveLength(0);
204
  });
205
206
  it("refuses a promotion with no explicit environment before sending any request", async () => {
207
    const requests: Array<ApiRequest> = [];
208
    const { run } = harness((input) =>
209
      Effect.sync(() => {
210
        requests.push(input);
211
        return { status: 202, body: targetBody("queued") };
212
      }),
213
    );
214
215
    await expect(
216
      run(["deploy", "promote", "--repo", "openagents.com", "--sha", fullSha]),
217
    ).rejects.toThrow(/--environment production explicitly/u);
218
    expect(requests).toHaveLength(0);
219
  });
220
221
  it("refuses a promotion with no repository before sending any request", async () => {
222
    const requests: Array<ApiRequest> = [];
223
    const { run } = harness((input) =>
224
      Effect.sync(() => {
225
        requests.push(input);
226
        return { status: 202, body: targetBody("queued") };
227
      }),
228
    );
229
230
    await expect(
231
      run(["deploy", "promote", "--sha", fullSha, "--environment", "production"]),
232
    ).rejects.toThrow(/--repo/u);
233
    expect(requests).toHaveLength(0);
234
  });
235
236
  it("waits to live and reports the terminal outcome", async () => {
237
    const requests: Array<ApiRequest> = [];
238
    const { run, written } = harness((input) =>
239
      Effect.sync(() => {
240
        requests.push(input);
241
        return input.method === "POST"
242
          ? { status: 202, body: targetBody("queued") }
243
          : { status: 200, body: targetBody("live") };
244
      }),
245
    );
246
247
    await run(["--json", ...promoteArgv(["--wait", "--idempotency-key", "release-key-0002"])]);
248
249
    expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([
250
      "POST /api/v3/admin/forge/targets",
251
      `GET /api/v3/admin/forge/targets/${targetId}`,
252
    ]);
253
    const value = written[0]?.document.value as Record<string, unknown>;
254
    expect(value["outcome"]).toBe("live");
255
    expect(value["live"]).toBe(true);
256
    expect(value["terminal"]).toBe(true);
257
  });
258
259
  it("reports a failed target as a deployment failure with its failure code", async () => {
260
    const { run, written } = harness((input) =>
261
      Effect.succeed(
262
        input.method === "POST"
263
          ? { status: 202, body: targetBody("queued") }
264
          : { status: 200, body: targetBody("failed", { error_code: "artifact_mismatch" }) },
265
      ),
266
    );
267
268
    await expect(
269
      run(["--json", ...promoteArgv(["--wait", "--idempotency-key", "release-key-0003"])]),
270
    ).rejects.toThrow(/reached failed \(artifact_mismatch\)/u);
271
    const value = written[0]?.document.value as Record<string, unknown>;
272
    expect(value["outcome"]).toBe("failed");
273
    expect(value["live"]).toBe(false);
274
    expect(value["failure_code"]).toBe("artifact_mismatch");
275
  });
276
277
  it("reports a reverted target as a deployment failure", async () => {
278
    const { run, written } = harness((input) =>
279
      Effect.succeed(
280
        input.method === "POST"
281
          ? { status: 202, body: targetBody("queued") }
282
          : { status: 200, body: targetBody("reverted") },
283
      ),
284
    );
285
286
    await expect(
287
      run(["--json", ...promoteArgv(["--wait", "--idempotency-key", "release-key-0004"])]),
288
    ).rejects.toThrow(/reached reverted/u);
289
    expect((written[0]?.document.value as Record<string, unknown>)["outcome"]).toBe("reverted");
290
  });
291
292
  it("reports needs_rolling_replace as its own condition, not a failure", async () => {
293
    const { run, written } = harness((input) =>
294
      Effect.succeed(
295
        input.method === "POST"
296
          ? { status: 202, body: targetBody("queued") }
297
          : { status: 200, body: targetBody("needs_rolling_replace") },
298
      ),
299
    );
300
301
    await expect(
302
      run(["--json", ...promoteArgv(["--wait", "--idempotency-key", "release-key-0005"])]),
303
    ).rejects.toThrow(/rolling replacement/u);
304
    const value = written[0]?.document.value as Record<string, unknown>;
305
    expect(value["outcome"]).toBe("needs_rolling_replace");
306
    expect(value["failure_code"]).toBeNull();
307
  });
308
309
  it("names the operator scope when the account is not an operator", async () => {
310
    const { run } = harness(() =>
311
      Effect.succeed(
312
        envelope(403, "not_operator", "The credential's account is not a current operator"),
313
      ),
314
    );
315
316
    await expect(run(promoteArgv(["--idempotency-key", "release-key-0006"]))).rejects.toThrow(
317
      /deployments:promote.*forge:write cannot promote.*auth login --scope deployments:promote/su,
318
    );
319
  });
320
321
  it("guides a forge:write-only token to the privileged login", async () => {
322
    const { run } = harness(() =>
323
      Effect.succeed(
324
        envelope(401, "unauthenticated", "Requires an API token carrying deployments:promote"),
325
      ),
326
    );
327
328
    await expect(run(["deploy", "view", targetId])).rejects.toThrow(
329
      /auth login --scope deployments:promote/u,
330
    );
331
  });
332
333
  it("shows one target through the status resource", async () => {
334
    const requests: Array<ApiRequest> = [];
335
    const { run, written } = harness((input) =>
336
      Effect.sync(() => {
337
        requests.push(input);
338
        return { status: 200, body: targetBody("deploying") };
339
      }),
340
    );
341
342
    await run(["--json", "deploy", "view", targetId]);
343
344
    expect(requests[0]?.path).toBe(`/api/v3/admin/forge/targets/${targetId}`);
345
    const value = written[0]?.document.value as Record<string, unknown>;
346
    expect(value["outcome"]).toBe("pending");
347
    expect(value["terminal"]).toBe(false);
348
  });
349
350
  it("lists bounded recent history with the requested repository and limit", async () => {
351
    const requests: Array<ApiRequest> = [];
352
    const { run, written } = harness((input) =>
353
      Effect.sync(() => {
354
        requests.push(input);
355
        return {
356
          status: 200,
357
          body: { repo: "openagents.com", targets: [targetBody("live")] },
358
        };
359
      }),
360
    );
361
362
    await run(["--json", "deploy", "list", "--repo", "openagents.com", "--limit", "5"]);
363
364
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets?repo=openagents.com&limit=5");
365
    expect(written[0]?.document.value).toEqual({
366
      repo: "openagents.com",
367
      targets: [targetBody("live")],
368
    });
369
  });
370
371
  it("stops polling on interrupt without sending a cancellation", async () => {
372
    const requests: Array<ApiRequest> = [];
373
    let polled: (() => void) | undefined;
374
    const firstPoll = new Promise<void>((resolvePoll) => {
375
      polled = resolvePoll;
376
    });
377
    const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
378
    try {
379
      const { program } = harness((input) =>
380
        Effect.sync(() => {
381
          requests.push(input);
382
          if (input.method === "GET") polled?.();
383
          return input.method === "POST"
384
            ? { status: 202, body: targetBody("queued") }
385
            : { status: 200, body: targetBody("building") };
386
        }),
387
      );
388
389
      const fiber = Effect.runFork(
390
        program(promoteArgv(["--wait", "--idempotency-key", "release-key-0007"])),
391
      );
392
      await Promise.race([
393
        firstPoll,
394
        new Promise<never>((_resolve, reject) =>
395
          setTimeout(() => reject(new Error("the CLI never polled the status resource")), 5_000),
396
        ),
397
      ]);
398
      await Effect.runPromise(Fiber.interrupt(fiber));
399
400
      const afterInterrupt = requests.length;
401
      // Only the promotion POST and status GETs ever went out; interruption
402
      // sent nothing to the server.
403
      expect(requests.every((request, index) => index === 0 || request.method === "GET")).toBe(
404
        true,
405
      );
406
      expect(requests[0]?.method).toBe("POST");
407
      await new Promise((resolveWait) => setTimeout(resolveWait, 50));
408
      expect(requests.length).toBe(afterInterrupt);
409
      const hints = errorSpy.mock.calls.map((call) => call.join(" ")).join("\n");
410
      expect(hints).toContain(`deploy view ${targetId}`);
411
    } finally {
412
      errorSpy.mockRestore();
413
    }
414
  });
415
});
packages/openagents-cli/test/errors.test.ts modified +25

@@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest";

3 3
import {
4 4
  ApiError,
5 5
  AuthenticationRequired,
6
  DeploymentFailed,
7
  DeploymentRollingReplaceRequired,
8
  DeploymentWaitTimeout,
6 9
  errorCode,
7 10
  exitCodeFor,
8 11
  InputError,

@@ -40,6 +43,28 @@ describe("CLI error contract", () => {

40 43
    ).toBe(7);
41 44
  });
42 45
46
  it("keeps deployment outcomes apart from each other and from transport failures", () => {
47
    // Auth (3), invalid input (2), conflict (5), and transport (6) are covered
48
    // above; a terminal deployment failure, a poll that outlived its budget,
49
    // and a required rolling replacement each carry their own exit class.
50
    expect(
51
      exitCodeFor(new DeploymentFailed({ targetId: "t-1", status: "failed", message: "failed" })),
52
    ).toBe(17);
53
    expect(
54
      exitCodeFor(
55
        new DeploymentWaitTimeout({
56
          targetId: "t-1",
57
          timeoutMs: 1_000,
58
          lastStatus: "building",
59
          message: "still building",
60
        }),
61
      ),
62
    ).toBe(18);
63
    expect(
64
      exitCodeFor(new DeploymentRollingReplaceRequired({ targetId: "t-1", message: "rolling" })),
65
    ).toBe(19);
66
  });
67
43 68
  it("preserves a stable server error code", () => {
44 69
    const error = new ApiError({
45 70
      operation: "create",
packages/openagents-cli/test/fleet-client.test.ts added +303

@@ -0,0 +1,303 @@

1
import { Effect, Fiber, Layer, Redacted } from "effect";
2
import { TestClock } from "effect/testing";
3
import { describe, expect, it } from "vitest";
4
5
import { apiTransportTestLayer, type ApiRequest, type ApiResponse } from "../src/api-transport.js";
6
import { TransportError } from "../src/errors.js";
7
import { FleetClient, fleetClientLayer } from "../src/fleet-client.js";
8
9
const token = Redacted.make("test-token");
10
11
const targetFixture = (status: string, overrides: Record<string, unknown> = {}) => ({
12
  id: "0d4e8a70-0000-4000-8000-000000000001",
13
  repo: "openagents.com",
14
  sha: "a".repeat(40),
15
  status,
16
  terminal: ["live", "failed", "reverted"].includes(status),
17
  promoted_by: "operator:1",
18
  environment: "production",
19
  source: "api",
20
  artifact_digest: null,
21
  deployment_lane: null,
22
  error_code: null,
23
  promoted_at: "2026-08-24T00:00:00Z",
24
  updated_at: "2026-08-24T00:00:00Z",
25
  status_url:
26
    "http://localhost:4000/api/v3/admin/forge/targets/0d4e8a70-0000-4000-8000-000000000001",
27
  ...overrides,
28
});
29
30
const promoteInput = {
31
  origin: "http://localhost:4000",
32
  token,
33
  repo: "openagents.com",
34
  sha: "a".repeat(40),
35
  environment: "production",
36
  idempotencyKey: "release-2026-08-24-a",
37
} as const;
38
39
const layerFromHandler = (
40
  handler: (input: ApiRequest) => Effect.Effect<ApiResponse, TransportError>,
41
): Layer.Layer<FleetClient> => fleetClientLayer.pipe(Layer.provide(apiTransportTestLayer(handler)));
42
43
describe("fleet client", () => {
44
  it("promotes with the exact repository, SHA, environment, and idempotency key", async () => {
45
    const requests: Array<ApiRequest> = [];
46
    const layer = layerFromHandler((input) =>
47
      Effect.sync(() => {
48
        requests.push(input);
49
        return { status: 202, body: { ...targetFixture("queued"), replayed: false } };
50
      }),
51
    );
52
53
    const result = await Effect.runPromise(
54
      Effect.gen(function* () {
55
        const fleet = yield* FleetClient;
56
        return yield* fleet.promote({
57
          ...promoteInput,
58
          expectedCurrentTargetId: "0d4e8a70-0000-4000-8000-000000000000",
59
        });
60
      }).pipe(Effect.provide(layer)),
61
    );
62
63
    expect(requests).toHaveLength(1);
64
    expect(requests[0]?.method).toBe("POST");
65
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets");
66
    expect(requests[0]?.body).toEqual({
67
      repo: "openagents.com",
68
      sha: "a".repeat(40),
69
      environment: "production",
70
      idempotency_key: "release-2026-08-24-a",
71
      expected_current_target_id: "0d4e8a70-0000-4000-8000-000000000000",
72
    });
73
    expect(result.accepted).toBe(true);
74
    expect(result.replayed).toBe(false);
75
  });
76
77
  it("reports a 200 replay as the original promotion, not a new acceptance", async () => {
78
    const layer = layerFromHandler(() =>
79
      Effect.succeed({ status: 200, body: { ...targetFixture("live"), replayed: true } }),
80
    );
81
82
    const result = await Effect.runPromise(
83
      Effect.gen(function* () {
84
        const fleet = yield* FleetClient;
85
        return yield* fleet.promote(promoteInput);
86
      }).pipe(Effect.provide(layer)),
87
    );
88
89
    expect(result.accepted).toBe(false);
90
    expect(result.replayed).toBe(true);
91
  });
92
93
  it("re-sends after a failed transport with the same idempotency key", async () => {
94
    const requests: Array<ApiRequest> = [];
95
    const layer = layerFromHandler((input) =>
96
      Effect.suspend(() => {
97
        requests.push(input);
98
        if (requests.length === 1) {
99
          return Effect.fail(
100
            new TransportError({
101
              operation: "sending the request",
102
              message: "connection closed",
103
              cause: new Error("closed"),
104
            }),
105
          );
106
        }
107
        return Effect.succeed({ status: 202, body: targetFixture("queued") });
108
      }),
109
    );
110
111
    const result = await Effect.runPromise(
112
      Effect.gen(function* () {
113
        const fiber = yield* Effect.gen(function* () {
114
          const fleet = yield* FleetClient;
115
          return yield* fleet.promote(promoteInput);
116
        }).pipe(Effect.provide(layer), Effect.forkChild);
117
        yield* TestClock.adjust("5 seconds");
118
        return yield* Fiber.join(fiber);
119
      }).pipe(Effect.provide(TestClock.layer())),
120
    );
121
122
    expect(result.accepted).toBe(true);
123
    expect(requests).toHaveLength(2);
124
    const keys = requests.map(
125
      (request) => (request.body as Record<string, unknown>)["idempotency_key"],
126
    );
127
    expect(keys).toEqual(["release-2026-08-24-a", "release-2026-08-24-a"]);
128
  });
129
130
  it("surfaces an exhausted transport as a transport failure, never a deployment failure", async () => {
131
    let attempts = 0;
132
    const layer = layerFromHandler(() =>
133
      Effect.suspend(() => {
134
        attempts += 1;
135
        return Effect.fail(
136
          new TransportError({
137
            operation: "sending the request",
138
            message: "connection closed",
139
            cause: new Error("closed"),
140
          }),
141
        );
142
      }),
143
    );
144
145
    const outcome = await Effect.runPromise(
146
      Effect.gen(function* () {
147
        const fiber = yield* Effect.gen(function* () {
148
          const fleet = yield* FleetClient;
149
          return yield* fleet.promote(promoteInput);
150
        }).pipe(Effect.provide(layer), Effect.flip, Effect.forkChild);
151
        yield* TestClock.adjust("10 seconds");
152
        return yield* Fiber.join(fiber);
153
      }).pipe(Effect.provide(TestClock.layer())),
154
    );
155
156
    expect(attempts).toBe(3);
157
    expect(outcome._tag).toBe("OpenAgentsCli.TransportError");
158
  });
159
160
  it("never re-sends a promotion the server refused", async () => {
161
    const requests: Array<ApiRequest> = [];
162
    const layer = layerFromHandler((input) =>
163
      Effect.sync(() => {
164
        requests.push(input);
165
        return {
166
          status: 409,
167
          body: {
168
            message: "The fleet target changed before this request",
169
            code: "precondition_failed",
170
            status: 409,
171
            request_id: "req-1",
172
            errors: {},
173
          },
174
        };
175
      }),
176
    );
177
178
    const failure = await Effect.runPromise(
179
      Effect.gen(function* () {
180
        const fleet = yield* FleetClient;
181
        return yield* fleet.promote(promoteInput);
182
      }).pipe(Effect.provide(layer), Effect.flip),
183
    );
184
185
    expect(requests).toHaveLength(1);
186
    expect(failure._tag).toBe("OpenAgentsCli.ApiError");
187
    expect(failure._tag === "OpenAgentsCli.ApiError" && failure.code).toBe("precondition_failed");
188
  });
189
190
  it("polls with bounded backoff until the target is live", async () => {
191
    const statuses = ["queued", "building", "deploying", "live"];
192
    let call = 0;
193
    const layer = layerFromHandler(() =>
194
      Effect.sync(() => {
195
        const status = statuses[Math.min(call, statuses.length - 1)] ?? "live";
196
        call += 1;
197
        return { status: 200, body: targetFixture(status) };
198
      }),
199
    );
200
201
    const result = await Effect.runPromise(
202
      Effect.gen(function* () {
203
        const fiber = yield* Effect.gen(function* () {
204
          const fleet = yield* FleetClient;
205
          return yield* fleet.wait({
206
            origin: "http://localhost:4000",
207
            token,
208
            id: "0d4e8a70-0000-4000-8000-000000000001",
209
            timeoutMs: 600_000,
210
          });
211
        }).pipe(Effect.provide(layer), Effect.forkChild);
212
        // Backoff is 2s, 4s, 8s; 20 seconds covers all three sleeps.
213
        yield* TestClock.adjust("20 seconds");
214
        return yield* Fiber.join(fiber);
215
      }).pipe(Effect.provide(TestClock.layer())),
216
    );
217
218
    expect(call).toBe(4);
219
    expect(result["status"]).toBe("live");
220
  });
221
222
  it("times out while nonterminal without claiming the deployment failed", async () => {
223
    let call = 0;
224
    const layer = layerFromHandler(() =>
225
      Effect.sync(() => {
226
        call += 1;
227
        return { status: 200, body: targetFixture("building") };
228
      }),
229
    );
230
231
    const failure = await Effect.runPromise(
232
      Effect.gen(function* () {
233
        const fiber = yield* Effect.gen(function* () {
234
          const fleet = yield* FleetClient;
235
          return yield* fleet.wait({
236
            origin: "http://localhost:4000",
237
            token,
238
            id: "0d4e8a70-0000-4000-8000-000000000001",
239
            timeoutMs: 5_000,
240
          });
241
        }).pipe(Effect.provide(layer), Effect.flip, Effect.forkChild);
242
        yield* TestClock.adjust("30 seconds");
243
        return yield* Fiber.join(fiber);
244
      }).pipe(Effect.provide(TestClock.layer())),
245
    );
246
247
    expect(call).toBeGreaterThan(1);
248
    expect(failure._tag).toBe("OpenAgentsCli.DeploymentWaitTimeout");
249
    if (failure._tag === "OpenAgentsCli.DeploymentWaitTimeout") {
250
      expect(failure.lastStatus).toBe("building");
251
      expect(failure.message).toContain("has not failed");
252
      expect(failure.message).toContain("deploy view");
253
    }
254
  });
255
256
  it("stops polling on needs_rolling_replace instead of waiting forever", async () => {
257
    const layer = layerFromHandler(() =>
258
      Effect.succeed({ status: 200, body: targetFixture("needs_rolling_replace") }),
259
    );
260
261
    const result = await Effect.runPromise(
262
      Effect.gen(function* () {
263
        const fleet = yield* FleetClient;
264
        return yield* fleet.wait({
265
          origin: "http://localhost:4000",
266
          token,
267
          id: "0d4e8a70-0000-4000-8000-000000000001",
268
          timeoutMs: 600_000,
269
        });
270
      }).pipe(Effect.provide(layer)),
271
    );
272
273
    expect(result["status"]).toBe("needs_rolling_replace");
274
  });
275
276
  it("lists targets through the bounded history route", async () => {
277
    const requests: Array<ApiRequest> = [];
278
    const layer = layerFromHandler((input) =>
279
      Effect.sync(() => {
280
        requests.push(input);
281
        return {
282
          status: 200,
283
          body: { repo: "openagents.com", targets: [targetFixture("live")] },
284
        };
285
      }),
286
    );
287
288
    await Effect.runPromise(
289
      Effect.gen(function* () {
290
        const fleet = yield* FleetClient;
291
        return yield* fleet.list({
292
          origin: "http://localhost:4000",
293
          token,
294
          repo: "openagents.com",
295
          limit: 5,
296
        });
297
      }).pipe(Effect.provide(layer)),
298
    );
299
300
    expect(requests[0]?.method).toBe("GET");
301
    expect(requests[0]?.path).toBe("/api/v3/admin/forge/targets?repo=openagents.com&limit=5");
302
  });
303
});

This page updates live while a promote is in flight · changelog