Skip to content

Commit 8dad650

Browse files
hassantsyedclaude
andcommitted
feat: add direct Doublespeed provider runtime
Adds `agent-device connect doublespeed`, a direct iOS-simulator provider backed by the Doublespeed Mac fleet. The provider mirrors the Limrun package: lease lifecycle with label-selector recovery, a session-API interactor, content-addressed app deployment, and an ADR-0019 platform runtime owner with durable app-log recovery. No new external dependency; the kernel snapshot provenance table gains the `doublespeed-ios-tree` producer. Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent 04758c9 commit 8dad650

60 files changed

Lines changed: 5067 additions & 31 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ The same session and evidence model works at every step: the agent explores the
137137
| --- | --- | --- |
138138
| Local | Trying commands and debugging apps on simulators, emulators, physical devices, macOS, and Linux. | Follow the Quick Start. |
139139
| CI/CD | Automated pull request and merge validation with replay scripts and captured artifacts. | Try the [EAS workflow template](https://github.com/callstackincubator/eas-agent-device/blob/main/.eas/workflows/agent-qa-mobile.yml). |
140-
| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, Limrun), or [contact Callstack](mailto:[email protected]) for team QA. |
140+
| Cloud / remote | Linux runners, managed devices, and remote jobs. | Set up a [remote proxy](https://oss.callstack.com/agent-device/docs/remote-proxy), connect a [device cloud](https://oss.callstack.com/agent-device/docs/device-clouds) (BrowserStack, AWS Device Farm, Limrun, Doublespeed), or [contact Callstack](mailto:[email protected]) for team QA. |
141141

142142
## How it works
143143

144144
`agent-device` keeps device state in sessions. It sends commands to XCTest on iOS and tvOS, ADB and the snapshot helper on Android, HDC and ArkUI `uitest` on HarmonyOS, Vega CLI/VDA on the Vega Virtual Device, a local helper on macOS, and AT-SPI on Linux.
145145

146146
Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target supports.
147147

148-
Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
148+
Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, Limrun, and Doublespeed](https://oss.callstack.com/agent-device/docs/device-clouds).
149149

150150
`agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system.
151151

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@
161161
"check:unit": "pnpm test:unit && pnpm check:tmpdir-leaks && pnpm test:smoke",
162162
"check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit",
163163
"prepack": "pnpm check:mcp-metadata && pnpm package:npm",
164-
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/capture-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
164+
"typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/capture-kit packages/platform-apple packages/platform-android packages/platform-harmonyos packages/platform-vega packages/platform-linux packages/platform-web packages/ad-script packages/selectors packages/ad-replay packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun packages/provider-doublespeed && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json",
165165
"test-app:install": "pnpm install --dir examples/test-app",
166166
"test-app:start": "pnpm --dir examples/test-app start",
167167
"test-app:ios": "pnpm --dir examples/test-app ios",
@@ -286,6 +286,7 @@
286286
"@agent-device/platform-linux": "workspace:*",
287287
"@agent-device/platform-vega": "workspace:*",
288288
"@agent-device/platform-web": "workspace:*",
289+
"@agent-device/provider-doublespeed": "workspace:*",
289290
"@agent-device/provider-limrun": "workspace:*",
290291
"@agent-device/provider-webdriver": "workspace:*",
291292
"@agent-device/replay-test": "workspace:*",

‎packages/kernel/src/snapshot-provenance.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,8 @@ test('snapshotStateProvenance extracts exactly the pair', () => {
3232
backend: 'xctest',
3333
producer: 'limrun-ios-tree',
3434
});
35+
expect(snapshotStateProvenance({ backend: 'xctest', producer: 'doublespeed-ios-tree' })).toEqual({
36+
backend: 'xctest',
37+
producer: 'doublespeed-ios-tree',
38+
});
3539
});

‎packages/kernel/src/snapshot.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export type SnapshotNode = RawSnapshotNode & {
150150
/**
151151
* The channel↔producer pairs that can actually occur. One channel is fed by several producers
152152
* with different guarantees: `xctest` trees come from the local Apple runner, Appium
153-
* page-source XML, or a limrun element tree, and only the runner's output has been through the
153+
* page-source XML, or a Limrun or Doublespeed element tree, and only the runner's output has been through the
154154
* runner's presentation (clip fold, effective geometry, scope). Logic that assumes
155155
* presentation, scope, or geometry guarantees must key on the producer, never on the channel
156156
* alone.
@@ -162,7 +162,10 @@ export type SnapshotNode = RawSnapshotNode & {
162162
* snapshot-provenance.test.ts).
163163
*/
164164
export type SnapshotProvenance =
165-
| { backend: 'xctest'; producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' }
165+
| {
166+
backend: 'xctest';
167+
producer: 'apple-runner' | 'appium-source' | 'limrun-ios-tree' | 'doublespeed-ios-tree';
168+
}
166169
| { backend: 'android'; producer: 'android-uiautomator' | 'appium-source' }
167170
| { backend: 'harmonyos-arkui'; producer: 'harmonyos-uitest' }
168171
| { backend: 'macos-helper'; producer: 'macos-helper' }
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@agent-device/provider-doublespeed",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Doublespeed provider runtime for agent-device. Internal workspace package bundled into the published artifact.",
7+
"dependencies": {
8+
"@agent-device/capture-kit": "workspace:*",
9+
"@agent-device/contracts": "workspace:*",
10+
"@agent-device/kernel": "workspace:*"
11+
},
12+
"exports": {
13+
".": {
14+
"types": "./src/index.ts",
15+
"default": "./src/index.ts"
16+
}
17+
}
18+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { expect, test } from 'vitest';
5+
import { DoublespeedApiClient } from './api-client.ts';
6+
import { readySimulator, scriptedFetch } from './runtime.fixtures.ts';
7+
8+
function client(fetchImpl: typeof fetch, apiUrl?: string) {
9+
return new DoublespeedApiClient({
10+
apiKey: 'dsx_test_key',
11+
clientVersion: '1.2.3',
12+
fetch: fetchImpl,
13+
...(apiUrl ? { apiUrl } : {}),
14+
});
15+
}
16+
17+
test('creates a simulator, identifies the CLI, and polls until the session is ready', async () => {
18+
const { fetch, calls } = scriptedFetch([
19+
() => ({
20+
status: 202,
21+
body: readySimulator({ ready: false, status: 'queued', api_url: null }),
22+
}),
23+
() => ({ body: readySimulator({ ready: false, status: 'preparing', api_url: null }) }),
24+
() => ({ body: readySimulator() }),
25+
]);
26+
27+
const simulator = await client(fetch, 'https://api.example/').createSimulator({
28+
device: 'iPhone 16 Pro',
29+
labels: { leaseId: 'lease-a' },
30+
idleTimeoutSeconds: 600,
31+
});
32+
33+
expect(simulator.ready).toBe(true);
34+
expect(simulator.api_url).toBe('https://worker.example/i/token-a');
35+
expect(calls.map((call) => `${call.init.method} ${call.url}`)).toEqual([
36+
'POST https://api.example/v1/xcode/simulators',
37+
'GET https://api.example/v1/xcode/simulators/sim-a?wait=1',
38+
'GET https://api.example/v1/xcode/simulators/sim-a?wait=1',
39+
]);
40+
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({
41+
device: 'iPhone 16 Pro',
42+
labels: { leaseId: 'lease-a' },
43+
idle_timeout_seconds: 600,
44+
wait: true,
45+
});
46+
expect(calls[0]?.init.headers).toMatchObject({
47+
authorization: 'Bearer dsx_test_key',
48+
'x-agent-device-client': 'agent-device-cli',
49+
'x-agent-device-version': '1.2.3',
50+
});
51+
});
52+
53+
test('fails closed when the simulator job ends before it is ready', async () => {
54+
const { fetch } = scriptedFetch([
55+
() => ({
56+
status: 202,
57+
body: readySimulator({
58+
ready: false,
59+
status: 'failed',
60+
api_url: null,
61+
error: { code: 'PREVIEW_SETUP', message: 'no simulator named "iPhone 3"' },
62+
}),
63+
}),
64+
]);
65+
await expect(client(fetch).createSimulator({ labels: {} })).rejects.toMatchObject({
66+
code: 'COMMAND_FAILED',
67+
details: { status: 'failed', providerError: { code: 'PREVIEW_SETUP' } },
68+
});
69+
});
70+
71+
test('classifies authentication and credit failures without echoing the key', async () => {
72+
const unauthorized = scriptedFetch([
73+
() => ({ status: 401, body: { error: { code: 'UNAUTHORIZED', message: 'bad key' } } }),
74+
]);
75+
await expect(client(unauthorized.fetch).listSimulators({})).rejects.toSatisfy(
76+
(error: unknown) => {
77+
expect(error).toMatchObject({ code: 'UNAUTHORIZED' });
78+
expect(JSON.stringify(error)).not.toContain('dsx_test_key');
79+
return true;
80+
},
81+
);
82+
const credits = scriptedFetch([
83+
() => ({
84+
status: 402,
85+
body: { error: { code: 'INSUFFICIENT_CREDITS', message: 'no credits' } },
86+
}),
87+
]);
88+
await expect(client(credits.fetch).createSimulator({ labels: {} })).rejects.toMatchObject({
89+
code: 'COMMAND_FAILED',
90+
details: { status: 402, providerCode: 'INSUFFICIENT_CREDITS' },
91+
});
92+
});
93+
94+
test('lists simulators by label selector and deletes by id', async () => {
95+
const { fetch, calls } = scriptedFetch([
96+
() => ({ body: { simulators: [readySimulator()] } }),
97+
() => ({ body: readySimulator({ status: 'cancelled', ready: false }) }),
98+
]);
99+
const api = client(fetch);
100+
const simulators = await api.listSimulators({ provider: 'doublespeed', leaseId: 'lease-a' });
101+
await api.deleteSimulator('sim-a');
102+
expect(simulators.map((simulator) => simulator.id)).toEqual(['sim-a']);
103+
expect(calls[0]?.url).toBe(
104+
'https://api.mac.doublespeed.ai/v1/xcode/simulators?label_selector=provider%3Ddoublespeed%2CleaseId%3Dlease-a',
105+
);
106+
expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe(
107+
'DELETE https://api.mac.doublespeed.ai/v1/xcode/simulators/sim-a',
108+
);
109+
});
110+
111+
test('registers, uploads and completes an asset with the signed URL as the only capability', async () => {
112+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doublespeed-asset-'));
113+
const filePath = path.join(tempDir, 'app.zip');
114+
fs.writeFileSync(filePath, 'zip-bytes');
115+
const { fetch, calls } = scriptedFetch([
116+
() => ({
117+
body: {
118+
sha256: 'abc',
119+
exists: false,
120+
upload_url: 'https://blob.example/upload?token=x',
121+
download_url: null,
122+
},
123+
}),
124+
() => ({ body: {} }),
125+
() => ({
126+
body: {
127+
sha256: 'abc',
128+
exists: true,
129+
upload_url: null,
130+
download_url: 'https://blob.example/get',
131+
},
132+
}),
133+
]);
134+
const api = client(fetch);
135+
const registered = await api.registerAsset({ sha256: 'abc', size: 9, name: 'app.zip' });
136+
await api.uploadAsset(registered.upload_url!, filePath);
137+
const completed = await api.completeAsset('abc', 9);
138+
139+
expect(completed.download_url).toBe('https://blob.example/get');
140+
expect(`${calls[1]?.init.method} ${calls[1]?.url}`).toBe(
141+
'PUT https://blob.example/upload?token=x',
142+
);
143+
expect(calls[1]?.init.headers).not.toHaveProperty('authorization');
144+
expect(String(calls[1]?.init.body)).toBe('zip-bytes');
145+
expect(calls[2]?.url).toBe('https://api.mac.doublespeed.ai/v1/xcode/assets/abc/complete');
146+
fs.rmSync(tempDir, { recursive: true, force: true });
147+
});

0 commit comments

Comments
 (0)