Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .claude/skills/cut-release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
---
name: cut-release
description: Cut a release of the four npm packages and the Flutter package - propose the version from the Unreleased changelog, bump versions in lockstep, open the release PR, and after it merges create the GitHub release that triggers publishing, only on an explicit yes. Use when asked to release, cut a version, tag or publish.
description: Cut a release of the five npm packages and the Flutter package - propose the version from the Unreleased changelog, bump versions in lockstep, open the release PR, and after it merges create the GitHub release that triggers publishing, only on an explicit yes. Use when asked to release, cut a version, tag or publish.
model: sonnet
effort: low
---

# Cut a release

The four npm packages (`@appduct/shared`, `appduct`, `@appduct/react-native`, `@appduct/web`) and the Flutter
The five npm packages (`@appduct/shared`, `appduct`, `@appduct/react-native`, `@appduct/web`, `@appduct/e2e`) and the Flutter
package (`appduct` on pub.dev) share one version. Podspecs and Gradle read it from `package.json`,
and the Flutter package carries it in `pubspec.yaml`, `darwin/appduct.podspec` and
`android/build.gradle.kts`, so the version lives in exactly seven files. Publishing is
`android/build.gradle.kts`, so the version lives in exactly eight files. Publishing is
`.github/workflows/deploy.yaml`, triggered by a published GitHub release whose tag is
`v<version>`; it refuses to run if the seven files disagree
`v<version>`; it refuses to run if the eight files disagree
(`node scripts/release-version.mjs`).

Two phases. Never run phase 2 inside a `work-issue` loop or without the explicit yes.
Expand All @@ -38,7 +38,7 @@ Read the `cut-release` section of `.agents/memory/LESSONS.md` before starting, p

```bash
v=<version>
for p in shared appduct react-native web; do
for p in shared appduct react-native web e2e; do
node -e "const f='packages/$p/package.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));j.version='$v';fs.writeFileSync(f,JSON.stringify(j,null,2)+'\n')"
done
```
Expand All @@ -52,7 +52,7 @@ Read the `cut-release` section of `.agents/memory/LESSONS.md` before starting, p
sed -i.bak -E "s/(s\.version +=) '[^']*'/\1 '$v'/" packages/flutter/darwin/appduct.podspec
sed -i.bak -E "s/^version = \"[^\"]*\"/version = \"$v\"/" packages/flutter/android/build.gradle.kts
rm packages/flutter/pubspec.yaml.bak packages/flutter/darwin/appduct.podspec.bak packages/flutter/android/build.gradle.kts.bak
node scripts/release-version.mjs # prints $v when all seven files agree
node scripts/release-version.mjs # prints $v when all eight files agree
(cd packages/flutter && dart pub publish --dry-run)
```

Expand Down
35 changes: 31 additions & 4 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
# restoring a pnpm store from a prior run's cache would let a poisoned cache entry slip
# dependencies into a published package. Every run resolves and installs cleanly.
- name: Validate production release version
# The four npm packages and the Flutter package must carry one version, and it must be
# The five npm packages and the Flutter package must carry one version, and it must be
# the release tag's.
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
Expand All @@ -57,6 +57,7 @@ jobs:
pnpm --filter appduct pack --pack-destination release
pnpm --filter @appduct/react-native pack --pack-destination release
pnpm --filter @appduct/web pack --pack-destination release
pnpm --filter @appduct/e2e pack --pack-destination release
- name: Upload release tarballs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
Expand Down Expand Up @@ -165,13 +166,39 @@ jobs:
- name: Publish web package
run: npm publish ./release/appduct-web-*.tgz --access public --provenance --tag latest

publish-e2e:
# Depends on the `appduct` package, so it goes out after the CLI.
needs: publish-cli
runs-on: ubuntu-latest
timeout-minutes: 10
environment: npm
permissions:
id-token: write
steps:
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24
- name: Verify npm version
run: >-
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number);
if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) process.exit(1);'
"$(npm --version)"
- name: Download release tarballs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: npm-packages
path: release
- name: Publish e2e package
run: npm publish ./release/appduct-e2e-*.tgz --access public --provenance --tag latest

publish-cocoapods:
# Deliberately last, after every npm publish has succeeded. A CocoaPods trunk version is
# permanent -- `pod trunk` has no unpublish, and a version can never be replaced or reused --
# whereas npm allows `npm unpublish` within 72 hours. So the irreversible half of the release
# only runs once the reversible half is known good; a trunk push that lands next to a failed
# npm publish would pin the repo to a version number it can never ship again.
needs: [publish-cli, publish-react-native, publish-web]
needs: [publish-cli, publish-react-native, publish-web, publish-e2e]
runs-on: macos-latest
timeout-minutes: 45
# Holds COCOAPODS_TRUNK_TOKEN (from `pod trunk register`). CocoaPods has no OIDC/trusted-
Expand Down Expand Up @@ -251,7 +278,7 @@ jobs:
#
# Re-run safe, like publish-cocoapods: an already-published version is skipped, and a deployment
# uploaded by an earlier attempt of this run is picked back up instead of being uploaded twice.
needs: [publish-cli, publish-react-native, publish-web]
needs: [publish-cli, publish-react-native, publish-web, publish-e2e]
runs-on: ubuntu-latest
timeout-minutes: 30
# Holds CENTRAL_TOKEN_USERNAME/PASSWORD (Central Portal user token) and the GPG key pair.
Expand Down Expand Up @@ -476,7 +503,7 @@ jobs:
# npm publishes. Nothing needs this job, so a pub.dev problem never fails the npm release; the
# publish run shows its own result under Actions > Publish to pub.dev and can be started again
# by hand on the tag.
needs: [publish-cli, publish-react-native, publish-web]
needs: [publish-cli, publish-react-native, publish-web, publish-e2e]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ cards, slides, diagrams, videos), follow [docs/DESIGN.md](docs/DESIGN.md).
| `packages/appduct` | CLI, daemon, MCP server, `appduct/client` (TypeScript) |
| `packages/shared` | Wire protocol and domain types shared by CLI and SDK, plus the TypeScript SDK layer (`/sdk`: client, schema conversion, tool groups; `/react`: the hook; `/inert`: the only entry a noop entry may import, no client code) |
| `packages/react-native` | React Native SDK, Expo config plugin, Metro helper |
| `packages/e2e` | `@appduct/e2e`: the `appduct` fixture and agent tools for the e2e test runner; `e2e/` is its suite against the playgrounds |
| `packages/native` | Framework-free iOS (Swift) and Android (Kotlin) core |
| `packages/flutter` | Flutter plugin `appduct`, outside the pnpm workspace (no `package.json`); `lib/src/core/` is a pure-Dart wire codec checked against `packages/native/fixtures` |
| `playground`, `playground-native`, `playground-flutter` | Expo app, native apps and a Flutter app (outside the pnpm workspace) registering the same five demo tools |
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ section into a versioned heading.

## Unreleased

- **New: an `AppClient` from `appduct/client` has `state()` and `revoke()`.** `state()` tells whether the app is still connected, and `revoke()` ends the session.
- **New: `@appduct/e2e` lets [e2e](https://github.com/tester-army/e2e) tests and their agent call your app's tools.** Wrap your `test` with `withAppduct` to get an `appduct` fixture, and add `appductTools()` to an agent; see the [package README](https://github.com/callstackincubator/appduct/tree/main/packages/e2e).
- **Fix: `waitForSession()` from `appduct/client` no longer sometimes times out when the app claims the session right after the wait starts.**

## 0.15.1 (2026-10-15)

- **Fix: a React Native build that leaves out Appduct's native module no longer crashes when the app registers a tool.** `registerTool`, `useAppductTool` and the rest of the API now do nothing in that build, as they should, instead of raising a fatal error in Release.
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import tseslint from "typescript-eslint";

const root = path.dirname(fileURLToPath(import.meta.url));
const PACKAGES = ["packages/appduct/src", "packages/shared/src", "packages/web/src"];
const PACKAGES = ["packages/appduct/src", "packages/shared/src", "packages/web/src", "packages/e2e/src"];
const SOURCE = PACKAGES.map((p) => `${p}/**/*.ts`);
// The device suite is test code throughout: it may reach the OS, but never mock a module.
const TESTS = [...PACKAGES.map((p) => `${p}/__tests__/**/*.ts`), "e2e-device/**/*.ts"];
Expand Down
36 changes: 36 additions & 0 deletions packages/appduct/src/__tests__/e2e/client.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,42 @@ describe("e2e: appduct/client", () => {
15_000,
);

test("state() reports an active session, and suspended once the app's connection drops", async () => {
const { stateDir } = await makeTempStateDir();
await ensureDaemon(stateDir);
const fakeApp = new FakeAppClient(await daemonWssPort(stateDir), await fetchPinnedKeys(stateDir));
const link = await mintLink(stateDir);
await fakeApp.claim(link);
const app = await connect({ stateDir, selector: link.sessionId });

expect(await app.state()).toBe("active");

const events = await subscribeToEvents(stateDir);
const suspended = events.waitFor("session_suspended");
fakeApp.dropSocket();
await suspended;
events.close();

expect(await app.state()).toBe("suspended");
app.close();
});

test("revoke() ends the session, so it no longer exists and the app is disconnected", async () => {
const { stateDir } = await makeTempStateDir();
await ensureDaemon(stateDir);
const fakeApp = new FakeAppClient(await daemonWssPort(stateDir), await fetchPinnedKeys(stateDir));
const link = await mintLink(stateDir);
await fakeApp.claim(link);
const app = await connect({ stateDir, selector: link.sessionId });
const disconnected = fakeApp.waitForClose();

await app.revoke();

expect(await disconnected).toMatchObject({ code: 1000 });
await expect(app.state()).rejects.toMatchObject({ type: "unknown_session" });
app.close();
});

test("connect() rejects with a connection_error AppductError when the daemon is unreachable and auto-spawn is disabled", async () => {
const { stateDir } = await makeTempStateDir();

Expand Down
13 changes: 12 additions & 1 deletion packages/appduct/src/__tests__/release-version.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function fixture({
}: Versions = {}): string {
const root = mkdtempSync(join(tmpdir(), "release-version-"));
roots.push(root);
for (const name of ["shared", "appduct", "react-native", "web"]) {
for (const name of ["shared", "appduct", "react-native", "web", "e2e"]) {
mkdirSync(join(root, "packages", name), { recursive: true });
writeFileSync(
join(root, "packages", name, "package.json"),
Expand Down Expand Up @@ -112,6 +112,17 @@ describe("release version check", () => {
expect(result.err).toContain("packages/web/package.json");
});

it("rejects an e2e package whose version differs from the other npm packages", () => {
const root = fixture();
writeFileSync(
join(root, "packages", "e2e", "package.json"),
JSON.stringify({ version: "0.14.0" }),
);
const result = run(root);
expect(result.code).toBe(1);
expect(result.err).toContain("packages/e2e/package.json");
});

it("rejects a prerelease version", () => {
const result = run(
fixture({
Expand Down
45 changes: 45 additions & 0 deletions packages/appduct/src/__tests__/rpc-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { randomUUID } from "node:crypto";

import { afterEach, describe, expect, test } from "vitest";

import { waitForSession } from "../client/index.js";
import { getStateDirPaths } from "../daemon/state-dir.js";
import {
callDaemon,
Expand Down Expand Up @@ -83,6 +84,8 @@ const startFakeDaemon = async (
pendingLinks?: number;
/** Accept the connection but never answer `daemon.status` — an alive-but-wedged daemon. */
stallStatus?: boolean;
/** The reply to any other method: its `result` or its `error`. */
answer?: (method: string) => { result: unknown } | { error: { code: number; message: string; data?: unknown } };
},
): Promise<FakeDaemon> => {
const paths = getStateDirPaths(stateDir);
Expand Down Expand Up @@ -163,6 +166,9 @@ const startFakeDaemon = async (
} else if (request.method === "daemon.shutdown") {
shutdownCalls += 1;
result = { ok: true };
} else if (options.answer) {
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, ...options.answer(request.method) })}\n`);
continue;
} else {
result = { answeredBy: id };
}
Expand Down Expand Up @@ -820,3 +826,42 @@ describe("daemon version check: a contended lock is not an ineffective restart (
});
});


describe("waitForSession", () => {
test("resolves for a session claimed just before its subscription took effect", async () => {
const stateDir = await makeTempStateDir();
// The app claims the session as the subscription arrives, too early for the subscription to
// carry a `session_claimed` notification.
let claimed = false;
await startFakeDaemon(stateDir, {
version: "9.9.9",
answer: (method) => {
if (method === "events.subscribe") claimed = true;
if (method !== "sessions.describe") return { result: {} };
return claimed
? { result: { sessionId: "s1" } }
: { error: { code: -32000, message: "unknown", data: { type: "unknown_session" } } };
},
});

const app = await waitForSession("s1", { stateDir, autoSpawn: false, timeoutMs: 1_000 });

expect(app.sessionId).toBe("s1");
app.close();
});

test("rejects at once when the daemon refuses the subscription", async () => {
const stateDir = await makeTempStateDir();
await startFakeDaemon(stateDir, {
version: "9.9.9",
answer: (method) =>
method === "events.subscribe"
? { error: { code: -32000, message: "No such session.", data: { type: "unknown_session" } } }
: { error: { code: -32000, message: "unknown", data: { type: "unknown_session" } } },
});

await expect(waitForSession("s1", { stateDir, autoSpawn: false, timeoutMs: 5_000 })).rejects.toMatchObject({
type: "unknown_session",
});
});
});
27 changes: 27 additions & 0 deletions packages/appduct/src/client/app-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
type EventsSinceResult,
type FullAppEvent,
type ListedToolDescriptor,
type SessionsDescribeResult,
type SessionState,
type ToolsCallResult,
type ToolsListResult,
} from "@appduct/shared";
Expand Down Expand Up @@ -115,6 +117,15 @@ export type { AppEvent, FullAppEvent } from "@appduct/shared";
export type AppClient<TTools = ToolMap> = {
readonly sessionId: string;

/** The session's state now: `active` while the app is connected, `suspended` while it is away
* (its process died, it was backgrounded or a page closed) and may still come back. Rejects with
* `"unknown_session"` once the session is gone. */
state(): Promise<SessionState>;

/** Ends the session: the daemon disconnects the app and forgets the session, so it can never
* resume. Works in any state. */
revoke(): Promise<void>;

/** `tools.list` for this session. An entry spells an ungrouped tool's `group` as `null`,
* where a registration omits it. */
tools(): Promise<ListedToolDescriptor[]>;
Expand Down Expand Up @@ -243,6 +254,22 @@ export const makeAppClient = <TTools = ToolMap>(
const client = {
sessionId,

state: async (): Promise<SessionState> => {
try {
return (await stream.call<SessionsDescribeResult>(RPC_METHODS.sessionsDescribe, { selector: sessionId })).state;
} catch (error) {
throw toAppductError(error);
}
},

revoke: async (): Promise<void> => {
try {
await stream.call(RPC_METHODS.sessionsRevoke, { selector: sessionId });
} catch (error) {
throw toAppductError(error);
}
},

tools: async (): Promise<ListedToolDescriptor[]> => {
try {
// No `filter`/`limit`/`offset`: this client's public `tools()` contract is "every tool on
Expand Down
27 changes: 13 additions & 14 deletions packages/appduct/src/client/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,17 +131,6 @@ export const waitForSession = async <TTools = ToolMap>(
openDaemonStream({ stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn });

try {
// The session may already be claimed by the time this runs — resolve immediately rather than
// waiting for an event that already happened.
try {
await stream.call<SessionsDescribeResult>(RPC_METHODS.sessionsDescribe, { selector: sessionId });
return makeAppClient<TTools>(stream, sessionId, openStream);
} catch (error) {
if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") {
throw error;
}
}

await new Promise<void>((resolve, reject) => {
let settled = false;

Expand Down Expand Up @@ -196,11 +185,21 @@ export const waitForSession = async <TTools = ToolMap>(
}
});

// Looks for the session only once subscribed: a claim before the subscription sent no
// notification this stream can see, so it must be found by looking, and a claim after it
// is seen by the listener above.
const fail = (error: unknown) => settle(() => reject(toAppductError(error)));
stream
.call(RPC_METHODS.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] })
.catch((error) => {
settle(() => reject(toAppductError(error)));
});
.then(() => {
stream.call<SessionsDescribeResult>(RPC_METHODS.sessionsDescribe, { selector: sessionId }).then(
() => settle(resolve),
// Not claimed yet: the subscription reports the claim when it comes.
(error) => {
if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") fail(error);
},
);
}, fail);
});

return makeAppClient<TTools>(stream, sessionId, openStream);
Expand Down
2 changes: 1 addition & 1 deletion packages/appduct/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ export type {
WaitForEventOptions,
} from "./app-client.js";
export type { TruncatedAppEvent } from "@appduct/shared";
export type { AgentEndpoint, ErrorType, ListedToolDescriptor, ToolDescriptor } from "@appduct/shared";
export type { AgentEndpoint, ErrorType, ListedToolDescriptor, SessionState, ToolDescriptor } from "@appduct/shared";
Loading
Loading