Skip to content

Add ms and Temporal.Duration inputs for CI - #1782

Merged
jpwilliams merged 26 commits into
mainfrom
jack/ci-durations-v3
Oct 19, 2026
Merged

jpwilliams merged 26 commits into
mainfrom
jack/ci-durations-v3

Conversation

@jpwilliams

Copy link
Copy Markdown
Member

Summary

Brings #1777's time inputs (number of milliseconds, ms-compatible string, or Temporal.Duration) to @inngest/ci's:

  • maxAge
  • keepOnFailure
  • .timeout()
  • waitForPort
  • waitForHttp
  • github.waitForChecks
  • waitForWorkflow
await $`pnpm test`.timeout(300_000);
await $`pnpm test`.timeout("5m");
await $`pnpm test`.timeout(Temporal.Duration.from({ minutes: 5 }));

Checklist

  • Added a docs PR that references this PR N/A Later bump
  • Added unit/integration tests
  • Added changesets if applicable

Related

x and others added 21 commits October 9, 2026 23:02
Jobs, commands and extra machines become spans, CI's steps are named for what
they do and marked as CI's work. A job's `name` sets its span name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every span is an entry in the `spans` catalog with a required kind, step IDs
are built by `joinId`, and starting a machine is a flat staged function in
`machine/start.ts`. Folds the CI half of ci2-span-kinds: commands, sandboxes,
saves and attempts carry kinds, each statement on a background process is a
row of its own, and a snapshot's span is passed to an SDK that takes it
(feature-detected by version) and opened by CI otherwise. Names the checkout
steps. Adds a `ciTest` harness.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving it to its own file cost more diff than it saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The span API and a snapshot's own span are always there, so the stub, the
version gate and the dual snapshot-span path are gone. The shared test
harness had one user, so it is local to that test again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he lookups

CI's step IDs and names move to one catalog in pipeline/names.ts, run through
a thin ciRun helper that tags the step as before. Sandbox paging and snapshot
deletes (machine/admin.ts), snapshot names (cache/names.ts), the cache lookup
and the check-complete payload each have one implementation. No step ID, name
or wire format changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cached snapshot older than cache.maxAge counts as a miss. One usableFor predicate decides whether a snapshot may be used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every duration reads through one durationToMs backed by the SDK's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… calls

A later direct call has the path `id (n)`, which is not a registry key, so
its span lost the configured name. The span now takes the name from the
job's config and keeps the call number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adapt the job span name fix into the steps catalog refactor: names.ts keeps
the catalog and takes traceName.job(config, path) from the base.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al to main's helpers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jpwilliams jpwilliams self-assigned this Oct 14, 2026
Copilot AI balanced review requested due to automatic review settings October 14, 2026 18:26
@changeset-bot

changeset-bot Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6ff5af2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@inngest/ci Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

jpwilliams and others added 2 commits October 14, 2026 18:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	packages/ci/src/pipeline/job.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Sandbox finalization can alter retry behavior, skip cleanup, or share resources across unrelated runs when async context is unavailable.

0 open findings

What changed in this PR

Extends the SDK’s shared duration handling to @inngest/ci, building on #1777. The changes also include sandbox lifecycle APIs and release updates.

Changes:

  • Accept milliseconds, duration strings, and Temporal.Duration across CI duration options.
  • Add scoped sandbox cleanup and middleware-supported sandbox groups.
  • Expand tests and update documentation and package compatibility.
File Description
packages/​inngest/​tsdown.config.ts Adds disposable declaration references.
packages/​inngest/​tsconfig.json Enables disposable types.
packages/​inngest/​src/​types.ts Generalizes middleware context extensions.
packages/​inngest/​src/​test/​helpers.ts Adds retry context to test executions.
packages/​inngest/​src/​index.ts Exports shared duration utilities.
packages/​inngest/​src/​helpers/​duration.ts Implements shared duration parsing.
packages/​inngest/​src/​helpers/​duration.test.ts Tests duration parsing and validation.
packages/​inngest/​src/​experimental.ts Exports sandbox group types.
packages/​inngest/​src/​components/​sandbox/​validation.ts Uses shared duration validation.
packages/​inngest/​src/​components/​sandbox/​types.ts Exposes disposable sandbox creation.
packages/​inngest/​src/​components/​sandbox/​trace.test.ts Reuses shared test fixtures.
packages/​inngest/​src/​components/​sandbox/​test-helpers.ts Adds sandbox lifecycle fixtures.
packages/​inngest/​src/​components/​sandbox/​middleware.ts Adds sandbox groups and cleanup warnings.
packages/​inngest/​src/​components/​sandbox/​lifetime.test.ts Tests disposal and cleanup failures.
packages/​inngest/​src/​components/​sandbox/​group.ts Implements callback-scoped sandbox lifetimes.
packages/​inngest/​src/​components/​sandbox/​group.test.ts Tests grouping and cleanup behavior.
packages/​inngest/​src/​components/​sandbox/​durable.ts Adds disposal and idempotent destruction.
packages/​inngest/​src/​components/​InngestSandbox.ts Re-exports sandbox group types.
packages/​inngest/​src/​components/​InngestSandbox.test.ts Tests sandbox duration limits.
packages/​inngest/​src/​components/​Inngest.ts Types middleware group extensions.
packages/​inngest/​package.json Updates SDK version.
packages/​inngest/​CHANGELOG.md Documents SDK release changes.
packages/​ci/​src/​version.ts Updates CI runtime version.
packages/​ci/​src/​util.ts Wraps shared parsing with CI errors.
packages/​ci/​src/​units.test.ts Expands duration validation tests.
packages/​ci/​src/​types.ts Broadens the public duration type.
packages/​ci/​src/​types.test.ts Checks expanded duration types.
packages/​ci/​src/​testing/​temporal.ts Adds a Temporal-shaped test fixture.
packages/​ci/​src/​pipeline/​pipeline.test.ts Tests timeout inputs and retention disabling.
packages/​ci/​src/​pipeline/​job.ts Validates job duration options.
packages/​ci/​src/​pipeline/​cacheGuarantees.test.ts Tests job duration validation.
packages/​ci/​src/​machine/​command.ts Normalizes command timeouts eagerly.
packages/​ci/​src/​github/​helpers.ts Normalizes GitHub wait timeouts.
packages/​ci/​src/​checkout/​wait.ts Uses normalized numeric timeouts.
packages/​ci/​src/​cache/​cache.ts Labels cache duration errors.
packages/​ci/​README.md Documents supported duration inputs.
packages/​ci/​package.json Updates version and SDK requirement.
packages/​ci/​CHANGELOG.md Adds CI release notes.
.changeset/​sandbox-snapshot-span-kind.md Removes released snapshot changeset.
.changeset/​ci-trace-spans.md Removes released tracing changeset.
.changeset/​ci-durations.md Adds duration support release notes.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] Adds duration input formats to CI and sandbox APIs.

Fix the sandbox cleanup retry path before merging; it can retry work after deleting the sandbox.

Findings

  1. P2 Timeout errors round the limit ▶
Fix with agent prompt
### Issue 1
packages/ci/src/machine/command.ts:393
`CommandTimeoutError` now uses `formatDuration()`, which rounds values of at least one second. For example, `.timeout(1500)` enforces 1,500 milliseconds but reports “timed out after 2s.” This makes timeout failures harder to explain. Keep the exact limit in the error with a formatter that preserves milliseconds.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds milliseconds and Temporal.Duration inputs to CI duration options through a shared SDK helper.

  • CI timing options accept numbers, strings, and Temporal.Duration values.
  • group.sandbox() runs callback work inside one sandbox trace group.
  • Sandbox scopes add a durable cleanup step when they end.
  • Earlier release notes and sandbox test fixtures move into shared files.

The await using documentation intentionally states that disposal also occurs before a retry. No supplied username accompanied that acknowledgment.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[CI duration input] --> B[Shared SDK durationToMs]
  B --> C{Positive whole milliseconds?}
  C -->|No| D[CiUsageError names the field]
  C -->|Yes| E[Cache age and job checks]
  C -->|Yes| F[Command timeout]
  C -->|Yes| G[Port HTTP and GitHub waits]
Loading

Reviews (1) · Last reviewed commit: "fix(ci): require inngest ^4.24.0, which ..." · Reviewed by Greptile

Comment thread packages/ci/src/machine/command.ts Outdated
@greptile-apps

greptile-apps Bot commented Oct 14, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Cleanup restarts failed work packages/inngest/src/components/sandbox/group.ts:81 ▶

    On the last attempt, a plain callback error makes group.sandbox() schedule a destroy step. After that step succeeds, the executor resumes with attempt = 0. The callback runs again, but isFinal() now treats the same error as retryable. The run gets more retries after its sandbox has already been destroyed.

    Preserve the final-failure decision across cleanup so retries cannot restart against a saved reference to a deleted sandbox.

  • P2 Scope example does not compile packages/inngest/CHANGELOG.md:22 ▶

    The new await using example omits required arguments. step.sandbox.create() needs create options, and the durable commands.run() call needs a step ID before the command. Users copying this example get TypeScript errors. Supply create options and call sb.commands.run("test", "npm test").

      await using sb = await step.sandbox.create("build", {
        name: "build",
        vcpu: 2,
        memoryMb: 2048,
      });
      await sb.commands.run("test", "npm test");
    

@jpwilliams

jpwilliams commented Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

group.sandbox() now always destroys its sandbox when the callback finishes (destroy is a step, so it is durable and retried on its own), with no attempt counts or hasRunStep() involved (dbabcd9, merged with main as 1cf05f7).
A plain error from the callback is rethrown as NonRetriableError with the original as cause; NonRetriableError and step errors are rethrown unchanged.

@jpwilliams
jpwilliams added this pull request to stack #1788 October 15, 2026 18:14
Base automatically changed from jack/ci-cache-max-age-v3 to main October 19, 2026 15:39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jpwilliams
jpwilliams merged commit c410e59 into main Oct 19, 2026
45 checks passed
@jpwilliams
jpwilliams deleted the jack/ci-durations-v3 branch October 19, 2026 16:04
jpwilliams pushed a commit that referenced this pull request Oct 19, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @inngest/ci@0.4.0

### Minor Changes

- [#1779](#1779)
[`314fb478`](314fb47)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Cached jobs can
now expire with `cache.maxAge`: a snapshot older than it is rebuilt.

  ```ts
ci.job({ id: "base", cache: { key: files("images/node-base/**"), maxAge:
"1d" } }, …);
  ```

- [#1780](#1780)
[`4491a641`](4491a64)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - `checkout()` and
`files()` can now read another repository or ref, including in runs
whose trigger has no repository.

  ```ts
  await checkout({ repo: "acme/shared-config", ref: "main" });
  ```

- [#1782](#1782)
[`c410e598`](c410e59)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Every duration
option (`cache.maxAge`, `keepOnFailure`, `.timeout()`, `waitForPort`,
`waitForHttp`, `github.waitForChecks`, `waitForWorkflow`) now takes
milliseconds, a string like `"1d"` or `"1h30m"`, or a
`Temporal.Duration`. A bad value throws a `CiUsageError` naming the
field. A bare numeric string like `"10"` now means 10 milliseconds.

  ```ts
  await waitForPort(3000, { timeout: "1m30s" });
  ```

- [#1803](#1803)
[`0e733c99`](0e733c9)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - A job can now
start from a job another app defines with `image.job()`. That app builds
it once for every app that asks.

  ```ts
  import { image } from "@inngest/ci";

ci.job({ id: "test", from: image.job("platform/node-base") }, async ()
=> {
    await # @inngest/cipnpm test`;
  });
  ```

- [#1803](#1803)
[`0e733c99`](0e733c9)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - You can now
define jobs inside a pipeline, and start other jobs `from` them. They
build in the run that defined them, and cached ones are still reused by
later runs.

  ```ts
  ci.pipeline({ id: "pr", on }, async () => {
    const base = ci.job("base", async () => {
      await # @inngest/cipnpm install`;
    });

    const lint = ci.job({ id: "lint", from: base }, async () => {
      await # @inngest/cipnpm lint`;
    });

    await lint();
  });
  ```

  Defining the same job ID twice in one run now throws a `CiUsageError`.

### Patch Changes

- [#1803](#1803)
[`0e733c99`](0e733c9)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Mistakes in a
job's `from` now fail when your app boots, with a message naming the
problem, instead of partway through a run. This covers a parent from
another CI client, a value that isn't a job, input that doesn't match
the parent's schema, and `from` cycles (which used to build forever).

  ```ts
  ci.job({ id: "test", from: notAJob }, …); // throws at boot
  ```
## inngest@4.25.0

### Minor Changes

- [#1802](#1802)
[`40af8b5b`](40af8b5)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Choose the image
a sandbox starts from with `image.inngest()` or `image.custom()`,
exported from `inngest/experimental`.

  ```ts
  import { image } from "inngest/experimental";

  await step.sandbox.create("build", {
    name: "build",
    vcpu: 2,
    memoryMb: 2048,
    from: image.inngest("base"),
  });

  await step.sandbox.create("custom", {
    name: "custom",
    vcpu: 2,
    memoryMb: 2048,
    from: image.custom("my-image:v1"),
  });
  ```

- [#1756](#1756)
[`5dc1279d`](5dc1279)
Thanks [@jakobevangelista](https://github.com/jakobevangelista)! -
Select an already-published image with `image` in
`inngest.sandboxes.create()` or `step.sandbox.create()`. Names, tags,
and artifact SHA-256 references are supported. Omission keeps the
default base image; snapshot clones retain their source image and cannot
select another. Sandbox resources expose an optional `imageDigest`
containing the pinned artifact digest.

### Patch Changes

- [#1801](#1801)
[`023ebfa3`](023ebfa)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Functions using
`optimizeParallelism: false` once again complete the run when the
function returns, as they did in v3. In v4 this changed unintentionally:
the run waited for steps you started but never awaited. If you don't use
`optimizeParallelism: false`, nothing changes.

  ```ts
  inngest.createFunction(
    {
      id: "example",
      optimizeParallelism: false,
      triggers: [{ event: "app/example" }],
    },
    async ({ step }) => {
      step.sleep("unused", "7d");

      return "done";
    },
  );
  ```

- [#1791](#1791)
[`b112a09e`](b112a09)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental:
code running in a sandbox can now call functions in your app. Pass
`tools` to `commands.run`, and each call the code makes runs your
function as its own durable step, so it's retried and memoized like any
other step.

  ```ts
  await sandbox.commands.run("agent", "node agent.js", {
    tools: {
      getCustomer: (id: string) => db.customers.find(id),
    },
  });
  ```

  ```js
  // agent.js, inside the sandbox
  const { tools } = require("inngest_tools");
  const customer = await tools.getCustomer("cus_1");
  ```

In Python, use `from inngest_tools import tools` and `await
tools.getCustomer("cus_1")`. A tool that fails raises a `ToolError` the
code can catch.

- [#1783](#1783)
[`167709c6`](167709c)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental: you
can now run Python in a sandbox with `step.sandbox.python`, available
with `sandboxMiddleware()`. It creates a sandbox, runs your code, and
destroys it, and a top-level `return` gives the result back. Pass data
in with `input`: each key becomes a variable. A non-zero exit fails the
step with the error output. Code and input of any size work, and results
can be as large as a step can return.

Pass a schema as `schema.output` to validate the result: it's typed from
the schema (as its JSON form, like `step.run`), and a result that
doesn't match fails the step with the schema's issues.

  ```ts
  const stats = await step.sandbox.python(
    "stats",
    `import statistics
  return {"mean": statistics.mean(values)}`,
    {
      input: { values: [1, 2, 3, 4] },
      schema: { output: z.object({ mean: z.number() }) },
    },
  );
  ```

Like any other step, it takes a step ID or step options, such as `{ id:
"stats", name: "Compute the mean" }`, as its first argument.

- [#1787](#1787)
[`dc120418`](dc12041)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental:
inline sandbox runner code can be indented to match your source; it's
dedented before it runs.

- [#1793](#1793)
[`27b3631c`](27b3631)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental:
`step.sandbox.python` and `step.sandbox.typescript` now take `tools`.
The code calls them as `tools.name(...)` with no import, and each call
runs your function as a durable step.

  ```ts
  const total = await step.sandbox.python(
    "total",
    `customer = await tools.getCustomer("cus_1")
  return customer["seats"] * 10`,
    {
      tools: { getCustomer: (id: string) => db.customers.find(id) },
      schema: { output: z.number() },
    },
  );
  ```

- [#1795](#1795)
[`e308aaa3`](e308aaa)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental:
sandbox code can now call `tool.group()` tools, whose body is a group of
real steps. Run steps in parallel, invoke other functions and use their
result, send events, or wait for one, all from a single tool call.

  ```ts
  import { step } from "inngest";
  import { tool } from "inngest/experimental";

  const refund = tool.group({
    input: z.object({ orderId: z.string() }),
    run: async ({ orderId }) => {
      const result = await step.invoke("refund", {
        function: refundFn,
        data: { orderId },
      });

await step.sendEvent("notify", { name: "order/refunded", data: result
});

      return result;
    },
  });
  ```

- [#1796](#1796)
[`0cc19e58`](0cc19e5)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental: an
Inngest function, or a `referenceFunction()` to one, can now be a
sandbox tool. The code calls it with named arguments, which become the
event data, and gets the function's result back. The invoke is a durable
step.

  ```ts
  import { eventType } from "inngest";

  const refund = inngest.createFunction(
    {
      id: "refund",
      triggers: [
        eventType("order/refund", {
          schema: z.object({ orderId: z.string() }),
        }),
      ],
    },
    async ({ event }) => issueRefund(event.data.orderId),
  );

  await sandbox.commands.run("agent", "node agent.js", {
    tools: { refund },
  });
  ```

The arguments are checked against the function's trigger schema when it
has one. Without a schema, any named arguments are sent; wrap the invoke
in `tool.group()` to declare the input you want.

- [#1794](#1794)
[`80016cf0`](80016cf)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental:
declare a sandbox tool with `tool.step()` to give it typed, named
arguments. The `input` schema is checked before anything runs, so a bad
call gets a `ToolError` back in the sandbox, and `output` checks the
result. A plain function in `tools` still works, and takes positional
arguments.

  ```ts
  import { tool } from "inngest/experimental";

  const getCustomer = tool.step({
    input: z.object({ id: z.string() }),
    output: z.object({ name: z.string() }),
    run: async ({ id }) => db.customers.find(id),
  });

  await sandbox.commands.run("agent", "node agent.js", {
    tools: { getCustomer },
  });
  ```

  ```js
  // agent.js, inside the sandbox
  const { tools } = require("inngest_tools");
  const customer = await tools.getCustomer({ id: "cus_1" });
  ```

`tool.step()` works with `commands.run` and with the Python and
TypeScript runners. Its body also receives an idempotency key that stays
the same across retries, for APIs that deduplicate writes.

- [#1784](#1784)
[`f5b71099`](f5b7109)
Thanks [@jpwilliams](https://github.com/jpwilliams)! - Experimental: you
can now run TypeScript in a sandbox with `step.sandbox.typescript`,
available with `sandboxMiddleware()`. `input` keys become variables, a
top-level `return` gives the result back, and top-level `await` works.
It takes the same `schema.output` as `step.sandbox.python` to validate
and type the result. The sandbox image needs Node 22.13 or later.

  ```ts
  const total = await step.sandbox.typescript(
    "sum",
    `return numbers.reduce((a: number, b: number) => a + b, 0)`,
    { input: { numbers: [1, 2, 3] } },
  );
  ```

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants