diff --git a/README.md b/README.md index 611aac0..d2a6600 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/HTTP_CLIENT.md b/docs/HTTP_CLIENT.md new file mode 100644 index 0000000..2569b32 --- /dev/null +++ b/docs/HTTP_CLIENT.md @@ -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. diff --git a/examples/http-client.mjs b/examples/http-client.mjs new file mode 100644 index 0000000..f66e679 --- /dev/null +++ b/examples/http-client.mjs @@ -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'); + } +} diff --git a/scripts/http-client.test.mjs b/scripts/http-client.test.mjs new file mode 100644 index 0000000..15711f4 --- /dev/null +++ b/scripts/http-client.test.mjs @@ -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)); } +});