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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ route and the forge co-location gate — and writes nothing and starts nothing
unless all of them answered. It never fetches credentials over the network; you
carry them in the bundle, and the reasoning is in `src/join.ts`.

**Integrating another application?** The [independent HTTP client](docs/HTTP_CLIENT.md)
shows task submission, progress, decisions, cancellation and follow-ups without
Akiroo or direct store access.

**First time? [docs/QUICKSTART.md](docs/QUICKSTART.md)** — nothing to a pull
request that carries its test result, skipping everything
you do not need to see the loop work.
Expand Down
58 changes: 58 additions & 0 deletions docs/HTTP_CLIENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Independent HTTP client

Ship works without Akiroo. `examples/http-client.mjs` is a dependency-free Node
22 example using the same external HTTP surfaces as the dashboard. It imports
no Ship runtime, store, or Akiroo code. First add the repository in Ship’s Projects
page with its test command and intended authority; an allowlisted forge alone
does not create a connected project. Supply your Ship origin and bearer token
through your own secret handling; never commit tokens or put them in URLs.

```js
import { randomUUID } from 'node:crypto';
import { ShipClient } from './examples/http-client.mjs';
const ship = new ShipClient(process.env.SHIP_URL, process.env.SHIP_WEB_TOKEN);
const requestId = randomUUID(); // persist before submission
const id = await ship.create({
task: 'Explain the request validation path, with file citations.',
repo: 'https://github.com/your-org/your-repo', journey: 'investigate', requestId,
});
console.log(await ship.workspace(id));
```

The workspace response exposes current metadata, conversation, verification,
artifacts and recorded activity. Poll it with a bounded deadline and backoff.
`waiting` requires inspecting its current `meta.eventName` and evidence. Present
that evidence to the person authorized to decide, then submit their exact choice:

```js
await ship.decide(id, {
eventName: reviewedEventName, approved: ownerApproved, reason: ownerReason,
// answer: ownerAnswer, // for a question
// plan: ownerEditedPlan, // for a plan decision
});
```

Never automatically approve an unfamiliar waiting event. `401` means credentials
are missing/invalid; `403` means the actor lacks authority; `409` means a decision
is stale or no longer applicable. A 409 is not successful approval: refresh the
workspace and require a new review. Ship rechecks authority and the current park.
A revoked credential must not be replaced with a more privileged one automatically.

`cancel(id)` requests cancellation and reads back the workspace; `cancelling` is
not terminal. Continue polling until the executor settles. `followUp(id, ... )`
starts linked work; pass a persisted requestId and choose `target: 'pr'` only when
you intend to continue an existing PR. Include the reviewed eventName when the
current review requires it. Follow-ups retain normal policy and verification.

The create/follow-up form endpoints return redirects; this client parses those
without following them or forwarding a bearer token elsewhere. Persist requestId
and the exact submitted fields. After a lost response, replay only that same
creation request; changing its fields under the same ID is refused. Decisions and
cancellation have no automatic retries: first read authoritative state, since a
lost response can hide an accepted action. Failures must remain visible.

These are the current dashboard-compatible interfaces, not a versioned general
SDK. Raw durable storage is deliberately absent. Workspace activity is the
recorded view, not an event-stream subscription. Contract tests do not prove a
particular live provider/forge configuration; installation and live lifecycle
receipts are separate acceptance evidence.
81 changes: 81 additions & 0 deletions examples/http-client.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/** A dependency-free Node 22 client for Ship's existing HTTP surfaces. */
export class ShipHTTPError extends Error {
constructor(status, operation, detail) {
super(`${operation}: HTTP ${status}${detail ? ` — ${detail}` : ''}`);
this.status = status;
}
}

export class ShipClient {
#base;
#token;
constructor(baseURL, token) {
const base = new URL(baseURL);
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password ||
base.search || base.hash || base.pathname !== '/') throw new Error('Use a Ship HTTP(S) origin');
if (!token || /[\r\n]/.test(token)) throw new Error('A Ship bearer token is required');
this.#base = base.origin;
this.#token = token;
}
async #request(path, init = {}) {
// No automatic mutation retries: a lost response may hide a completed act.
return fetch(`${this.#base}${path}`, {
...init, redirect: 'manual', signal: AbortSignal.timeout(30_000),
headers: { ...init.headers, authorization: `Bearer ${this.#token}` },
});
}
#runPath(runId) {
if (!/^run-[A-Za-z0-9-]+$/.test(runId)) throw new Error('Invalid Ship run ID');
return `/runs/${runId}`;
}
async #form(path, fields, operation) {
const response = await this.#request(path, {
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
});
const location = response.headers.get('location') ?? '';
const url = new URL(location || '/', this.#base);
const refusal = url.searchParams.get('error') || url.searchParams.get('messageError') ||
url.searchParams.get('denied') || (url.searchParams.get('cancel') === 'failed' ? 'cancel failed' : '');
const match = url.pathname.match(/^\/runs\/(run-[A-Za-z0-9-]+)$/);
if (![302, 303].includes(response.status) || url.origin !== this.#base || !match || refusal) {
throw new ShipHTTPError(response.status, operation, refusal || 'request refused or unexpected redirect');
}
return match[1];
}
async #json(path, init, operation) {
const response = await this.#request(path, init);
if (!response.ok) {
// Keep 401/403/409 as refusals. In particular, 409 never means approved.
throw new ShipHTTPError(response.status, operation, (await response.text()).slice(0, 500));
}
return response.json();
}
create({ task, repo, journey = 'change', requestId }) {
if (!task || !repo || !requestId) throw new Error('task, repo and stable requestId are required');
return this.#form('/', { intent: 'new-run', task, repo, journey, requestId }, 'create run');
}
workspace(runId) {
return this.#json(`/api${this.#runPath(runId)}/workspace`, {}, 'read workspace');
}
decide(runId, { eventName, approved, reason, answer, plan }) {
if (!eventName || typeof approved !== 'boolean') throw new Error('Reviewed eventName and explicit approved boolean are required');
return this.#json(`/api${this.#runPath(runId)}/decide`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ event_name: eventName, approved, reason, answer, plan }),
}, 'decide run');
}
async cancel(runId) {
const returned = await this.#form(this.#runPath(runId), { intent: 'cancel' }, 'request cancellation');
if (returned !== runId) throw new Error('Cancellation returned a different run');
// Acceptance is not settlement: inspect the authoritative current state.
return this.workspace(runId);
}
followUp(runId, { message, journey = 'change', requestId, target = 'base', eventName }) {
if (!message || !requestId) throw new Error('message and stable requestId are required');
return this.#form(this.#runPath(runId), {
intent: 'follow-up', message, journey, requestId, target,
...(eventName ? { eventName } : {}),
}, 'follow-up');
}
}
46 changes: 46 additions & 0 deletions scripts/http-client.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { test } from 'node:test';
import { ShipClient, ShipHTTPError } from '../examples/http-client.mjs';

test('independent client preserves refusals, decisions and cancellation state over HTTP', async () => {
const requests = [];
let responseMode = 'normal';
const server = createServer(async (req, res) => {
let body = ''; for await (const part of req) body += part;
requests.push({ path: req.url, body, authorization: req.headers.authorization });
if (req.headers.authorization !== 'Bearer test-only') { res.writeHead(401); res.end('unauthorized'); return; }
if (responseMode === 'foreign') { res.writeHead(303, { location: 'http://example.invalid/runs/run-foreign' }); res.end(); return; }
if (responseMode === 'denied') { res.writeHead(303, { location: '/runs/run-one?denied=steer' }); res.end(); return; }
if (req.url.endsWith('/decide')) {
const decision = JSON.parse(body);
res.writeHead(decision.event_name === 'current' ? 200 : 409, { 'content-type': 'application/json' });
res.end(JSON.stringify(decision.event_name === 'current' ? { ok: true } : { error: 'stale decision' })); return;
}
if (req.url.endsWith('/workspace')) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ meta: { status: 'cancelling' } })); return; }
res.writeHead(303, { location: '/runs/run-one' }); res.end();
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const origin = `http://127.0.0.1:${server.address().port}`;
const ship = new ShipClient(origin, 'test-only');
const request = { task: 'explain', repo: 'https://example.invalid/repo', journey: 'investigate', requestId: 'persisted-id' };
assert.equal(await ship.create(request), 'run-one');
assert.equal(await ship.create(request), 'run-one');
assert.equal(requests[0].body, requests[1].body);
assert.equal(new URLSearchParams(requests[0].body).get('requestId'), 'persisted-id');
assert.deepEqual(await ship.decide('run-one', { eventName: 'current', approved: false, reason: 'owner declined' }), { ok: true });
assert.equal(JSON.parse(requests.at(-1).body).approved, false);
const before = requests.length;
await assert.rejects(ship.decide('run-one', { eventName: 'old', approved: true }), e => e instanceof ShipHTTPError && e.status === 409);
assert.equal(requests.length, before + 1, 'does not retry a stale decision');
assert.equal((await ship.cancel('run-one')).meta.status, 'cancelling');
await assert.rejects(new ShipClient(origin, 'wrong').workspace('run-one'), e => e.status === 401);
responseMode = 'foreign';
await assert.rejects(ship.create(request), /unexpected redirect/);
responseMode = 'denied';
await assert.rejects(ship.cancel('run-one'), /steer/);
assert.throws(() => ship.decide('run-one', { approved: true }), /eventName/);
assert.throws(() => ship.workspace('../settings'), /Invalid Ship run ID/);
} finally { await new Promise(resolve => server.close(resolve)); }
});
Loading