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
8 changes: 8 additions & 0 deletions docs/context-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ Context Service:
the workload's frozen revision available read-only.
5. **Delete:** `DELETE /workloads/{id}` deletes the Context, then the workload.

`mocactl` is the client for this flow
([#495](https://github.com/rossoctl/moca/issues/495), one CLI for delegating subagent tasks;
[#517](https://github.com/rossoctl/moca/issues/517), seed sandbox files before a leaf runs):
`mocactl workloads create --context FILE` does steps 1 to 3, `mocactl spawn --kind prompt --workload ID`
step 4, and `mocactl workloads delete ID` step 5. It does not build the context file:
`contextctl ctx export` does. See "Delegate with context" in
[`packages/mocactl/README.md`](../packages/mocactl/README.md).

The routes are off by default. With them off, every `/workloads` route answers
`501 workloads_unavailable`.

Expand Down
26 changes: 23 additions & 3 deletions docs/reference/cli-mocactl.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ All flags are accepted by one parser. An unknown flag, or a missing flag value,
exits 2. Some flags are always rejected (exit 2) outside their command: `--consumer`, `--host` and
`--endpoint` outside `credentials add`; `--kind` outside `credentials add` and `spawn`; `--config`
outside `run` and `spawn`; `--repo` and `--ref` outside `spawn`; `--tag` outside `spawn` and
`subagents`; `--out` outside `get`; and `--all` outside `logout`. Other flags a command does not use
`subagents`; `--out` outside `get`; `--workload` outside `spawn`; `--context` and `--name` outside
`workloads create`; and `--all` outside `logout`. Other flags a command does not use
are not rejected and may be ignored.

## Endpoints
Expand Down Expand Up @@ -73,6 +74,9 @@ reachable?` instead; `auth token` exits 3 (login required) or 4; `logout` while
| `mocactl spawn "prompt" [--config DIGEST] --kind prompt\|solve [--repo URL --ref REF] [--tag T] [--json]` | Creates a session, writes a ledger record, forks a detached worker that holds a `POST /v1/runs`. Prints the sessionId on stdout |
| `mocactl subagents [--tag T] [--json]` | Lists spawned subagents from the ledger joined with `GET /runs/status` |
| `mocactl get SESSIONID [--out DIR] [--json]` | Fetches a terminal result from `GET /runs/status` and writes `<sid>.text` / `<sid>.verdict.json` / `<sid>.patch` |
| `mocactl workloads create --context FILE [--name NAME] [--json]` | `POST /workloads`, uploads `FILE` to the returned grant, then `POST /workloads/{id}/activate`. Prints the workload id |
| `mocactl workloads get ID [--json]` | Reads a workload (`GET /workloads/{id}`) |
| `mocactl workloads delete ID [--json]` | Deletes a workload and its context (`DELETE /workloads/{id}`) |

### `run`

Expand Down Expand Up @@ -113,17 +117,33 @@ for behavior; this section covers only CLI-reference details.

- `spawn` exit 2: empty prompt, prompt over 64 KiB, bundle digest not matching `sha256:<64 lowercase
hex>`, `--kind` other than `prompt`/`solve`, `--kind solve` without both `--repo` and `--ref`, or
`--repo`/`--ref` on `--kind prompt`.
`--repo`/`--ref` on `--kind prompt`, `--workload` without `--kind prompt`, or a workload id that
is not a lowercase Kubernetes name of at most 50 characters.
- `spawn` on a sync 503 `saturated`: the worker retries up to 3 times honoring `Retry-After`,
bounded by a 5-minute wall budget; after that the ledger row is `failed` with
`stateDetail: 'saturated — exhausted retries'`.
- `get` exit 2: sessionId outside `[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]`. Exit 0 only on
`responded`/`done`/`solved`; `queued`/`paused`/`failed` exit 1.
- `--json`:
- `spawn` → `{"type":"session","sessionId","pid"}`.
- `subagents` → `{"subagents":[{"sessionId","tag","kind","state","detail","bundle","submittedAt"}]}`.
- `subagents` → `{"subagents":[{"sessionId","tag","kind","state","detail","bundle","workload","submittedAt"}]}`.
- `get` → `{"sessionId","status","files":[…],"reason"?,"message"?}`.

### `workloads`

See "Delegate with context" in [`packages/mocactl/README.md`](../../packages/mocactl/README.md) and
[`docs/context-service.md`](../context-service.md). The routes are on the harness and take the API
token. There is no list command: the harness has no list route.

- `create` exit 2: no `--context`, a missing, empty or non-regular file, or an invalid `--name`; all
before any request. Exit 1 for a refusal, including a file larger than the grant's `maxBytes` or an
upload URL that is neither HTTPS nor loopback HTTP; a workload created before the failure is deleted.
- `get`/`delete` exit 2 for an invalid id, exit 1 for `404 workload_not_found` or another refusal.
- `--json`:
- `create` → `{"workloadId","status","contextType","revision","files","bytes"}`.
- `get` → `{"workloadId","status","contextType","workspace","revision"}` (`null` when unset).
- `delete` → `{"workloadId","status":"deleted"}`.

### `promote` exit codes

| Code | Cause |
Expand Down
42 changes: 41 additions & 1 deletion packages/mocactl/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ mocactl spawn "prompt" [--config sha256:…] --kind prompt|solve \
[--repo URL --ref REF] [--tag T] [--json] # fork a detached subagent; prints its sessionId
mocactl subagents [--tag T] [--json] # list spawned subagents + current honest state
mocactl get SESSIONID [--out DIR] [--json] # write result files + bounded summary
mocactl spawn "prompt" --kind prompt --workload ID [...] # run it on a workload's context
```

```bash
mocactl workloads create --context FILE [--name NAME] [--json] # upload a context; prints its id
mocactl workloads get ID [--json] # status, context type, size and frozen revision
mocactl workloads delete ID [--json] # delete the workload and its context
```

`mocactl spawn` creates a session (pinned to the bundle if `--config` is given), writes a record
Expand Down Expand Up @@ -160,6 +167,36 @@ Exit codes, for every command: `0` it worked, `1` it failed (the turn, or the co
refused), `2` a usage or setup problem (bad flags, not logged in, no destination for the turn),
`130` cancelled (Ctrl-C, which also stops a listing or a stdin read that is waiting).

### Delegate with context

A workload gives a subagent files to work from: a context, uploaded once and available read-only
to every run on it ([`docs/context-service.md`](../../docs/context-service.md)). Build the context
file with `contextctl` (`mocactl` never calls it), then:

```bash
contextctl ctx export ... -o ctx.context
id=$(mocactl workloads create --context ctx.context --name demo)
sid=$(mocactl spawn "summarize the repo in the context" --kind prompt --workload "$id")
mocactl get "$sid" # once `mocactl subagents` says done
mocactl workloads delete "$id"
```

`workloads create` creates the workload (`POST /workloads` on the harness, with your API token),
uploads the file straight to Context Service with the one-time grant it returns, then activates the
revision the upload reports, which freezes it. It checks that the file exists before it starts, and
that it is no larger than the grant allows before it uploads; the upload streams from disk. A grant
that has expired, or that the upload refuses with `401`, is replaced once
(`POST /workloads/ID/uploads`). The grant's URL must be HTTPS (plain HTTP only on loopback). If
anything fails after the workload exists, `create` deletes it again, so it does not count against
your quota (5 live workloads by default), and exits `1`. `--name` is a lowercase Kubernetes name of
at most 50 characters; without one the harness generates it. The harness has no route that lists
workloads, so there is no `mocactl workloads` listing: keep the id `create` prints.

`spawn --workload ID` adds `workloadId` to the run and records it in the ledger row
(`mocactl subagents --json` shows it as `workload`). Only prompt runs can use a workload, so
`--workload` needs `--kind prompt`. A workload that is not `ready`, or not yours, fails the run
(`workload_not_ready`, `workload_not_found`), as `mocactl subagents` then shows.

### Output

stdout carries only the result; every message goes to stderr, including "no sessions" for an
Expand All @@ -175,8 +212,11 @@ their own shapes. These shapes are stable: fields may be added, but none is rena
| `credentials add` | `{"name","status":"stored"}` |
| `credentials delete` | `{"name","status":"deleted"}` |
| `spawn` | `{"type":"session","sessionId","pid"}` |
| `subagents` | `{"subagents":[{"sessionId","tag","kind","state","detail","bundle","submittedAt"}]}` |
| `subagents` | `{"subagents":[{"sessionId","tag","kind","state","detail","bundle","workload","submittedAt"}]}` |
| `get` | `{"sessionId","status","files":["<path>",…],"reason"?,"message"?}` |
| `workloads create` | `{"workloadId","status","contextType","revision","files","bytes"}` |
| `workloads get` | `{"workloadId","status","contextType","workspace","revision"}`, `null` for what is not set |
| `workloads delete` | `{"workloadId","status":"deleted"}` |

`createdAt` and `lastTurnAt` are epoch milliseconds as the control plane sends them (`lastTurnAt`
is `null` before the first turn); `endpoint` is `null` when the deployment default applies.
Expand Down
258 changes: 258 additions & 0 deletions packages/mocactl/src/api/workloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* Client for the harness's `/workloads` routes (docs/context-service.md) and the one-time upload
* of a context file straight to Context Service. The routes take the user's API token; the upload
* takes only the grant's own token.
*/
import { createReadStream } from 'node:fs';
import { Readable } from 'node:stream';
import { ApiError, errorFromResponse, networkError } from './errors.js';
import type { LeafHarness } from '../transport/leaf-turn.js';
import { trimTrailingSlashes } from './url.js';

/** The media type Context Service accepts on an upload. */
export const CONTEXT_MEDIA_TYPE = 'application/vnd.rossoctl.context';
/** A workload name: a lowercase Kubernetes name, as the harness checks it. */
export const WORKLOAD_ID = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/;
export const MAX_WORKLOAD_ID_LENGTH = 50;
const REVISION = /^[a-f0-9]{64}$/;

export function validWorkloadId(id: string): boolean {
return id.length <= MAX_WORKLOAD_ID_LENGTH && WORKLOAD_ID.test(id);
}

/** A one-time grant to PUT one context file to Context Service. */
export interface UploadGrant {
uploadUrl: string;
token: string;
expiresAt: string;
method: 'PUT';
contentType: string;
maxBytes: number;
}

export interface Workload {
workloadId: string;
status: string;
contextType?: string;
workspace?: { size?: string; storageClass?: string };
revision?: string;
}

export interface CreateWorkloadRequest {
name?: string;
}

export interface UploadResult {
revision: string;
files?: number;
bytes?: number;
}

/** A refusal from Context Service's upload route; `status` 0 means it was never reached. */
export class ContextUploadError extends Error {
constructor(
readonly status: number,
readonly code: string,
message?: string,
) {
super(message ?? code);
this.name = 'ContextUploadError';
}
}

/**
* Where an upload may go: HTTPS, or plain HTTP on loopback only (local development), the rule the
* harness applies to `CONTEXT_SERVICE_PUBLIC_URL`. Anything else would send the grant's token in
* the clear. Returns the reason it is refused, or undefined.
*/
export function uploadUrlProblem(raw: string): string | undefined {
let url: URL;
try {
url = new URL(raw);
} catch {
return 'the upload URL is not a URL';
}
if (url.protocol === 'https:') return undefined;
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
if (url.protocol === 'http:' && loopback) return undefined;
return 'the upload URL must use HTTPS (plain HTTP only on loopback)';
}

export class WorkloadsClient {
constructor(
private readonly harness: LeafHarness,
/** The user's API token, refreshed if needed; read before every request. */
private readonly apiToken: () => Promise<string | undefined>,
private readonly fetchImpl: typeof fetch = fetch,
) {}

/** POST /workloads: the workload, awaiting upload, plus its first grant. */
async create(
req: CreateWorkloadRequest,
signal?: AbortSignal,
): Promise<Workload & { upload: UploadGrant }> {
const body = await this.request('POST', '/workloads', req, signal);
const workload = asWorkload(body);
const upload = asGrant((body as { upload?: unknown }).upload);
return { ...workload, upload };
}

/** POST /workloads/{id}/uploads: a fresh grant while the workload awaits upload. */
async freshUpload(id: string, signal?: AbortSignal): Promise<UploadGrant> {
return asGrant(
await this.request('POST', `/workloads/${encodeURIComponent(id)}/uploads`, undefined, signal),
);
}

/** POST /workloads/{id}/activate: freeze the uploaded revision; the workload becomes ready. */
async activate(id: string, revision: string, signal?: AbortSignal): Promise<Workload> {
return asWorkload(
await this.request(
'POST',
`/workloads/${encodeURIComponent(id)}/activate`,
{ revision },
signal,
),
);
}

async get(id: string, signal?: AbortSignal): Promise<Workload> {
return asWorkload(
await this.request('GET', `/workloads/${encodeURIComponent(id)}`, undefined, signal),
);
}

/** DELETE /workloads/{id}: 204. */
async delete(id: string, signal?: AbortSignal): Promise<void> {
await this.request('DELETE', `/workloads/${encodeURIComponent(id)}`, undefined, signal);
}

/**
* PUT the file at `path` (`size` bytes) to the grant's URL, streamed from disk rather than read
* into memory. Throws ContextUploadError for a refusal or an unusable reply.
*/
async upload(
grant: UploadGrant,
path: string,
size: number,
signal?: AbortSignal,
): Promise<UploadResult> {
const problem = uploadUrlProblem(grant.uploadUrl);
if (problem) throw new ContextUploadError(0, 'upload_url_unsafe', problem);
const stream = createReadStream(path);
let res: Response;
try {
res = await this.fetchImpl(grant.uploadUrl, {
method: 'PUT',
headers: {
authorization: `Bearer ${grant.token}`,
'content-type': CONTEXT_MEDIA_TYPE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: asGrant keeps grant.contentType, but the PUT always sends CONTEXT_MEDIA_TYPE. They're equal today (the server enforces it), but sending grant.contentType would make the field meaningful. Otherwise drop it from UploadGrant.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks. Addressed in #532 (writable per-run copy, stacked on this PR).

'content-length': String(size),
},
body: Readable.toWeb(stream) as ReadableStream<Uint8Array>,
// Required by Node's fetch for a streamed request body.
duplex: 'half',
signal,
} as RequestInit & { duplex: 'half' });
} catch (err) {
stream.destroy();
if (signal?.aborted) throw err;
throw new ContextUploadError(
0,
'network_error',
`cannot reach Context Service: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (!res.ok) {
const e = await errorFromResponse('harness', res);
throw new ContextUploadError(res.status, e.code, e.message);
}
const body = (await res.json().catch(() => null)) as Partial<UploadResult> | null;
if (!body || typeof body.revision !== 'string' || !REVISION.test(body.revision)) {
throw new ContextUploadError(
res.status,
'invalid_body',
'Context Service returned no revision',
);
}
return {
revision: body.revision,
...(typeof body.files === 'number' ? { files: body.files } : {}),
...(typeof body.bytes === 'number' ? { bytes: body.bytes } : {}),
};
}

private async request(
method: string,
path: string,
body: unknown,
signal?: AbortSignal,
): Promise<unknown> {
const base = trimTrailingSlashes(await this.harness.baseUrl());
const token = await this.apiToken();
let res: Response;
try {
res = await this.fetchImpl(`${base}${path}`, {
method,
headers: {
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(token ? { authorization: `Bearer ${token}` } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
signal,
});
} catch (err) {
if (signal?.aborted) throw err;
throw networkError('harness', err);
}
if (!res.ok) throw await errorFromResponse('harness', res);
if (res.status === 204) return undefined;
try {
return await res.json();
} catch {
throw new ApiError('harness', 0, 'invalid_body', `the harness returned non-JSON for ${path}`);
}
}
}

function invalid(what: string): ApiError {
return new ApiError('harness', 0, 'invalid_body', `the harness returned an invalid ${what}`);
}

function asWorkload(body: unknown): Workload {
const b = body as Partial<Workload> | null;
if (!b || typeof b.workloadId !== 'string' || typeof b.status !== 'string') {
throw invalid('workload');
}
return {
workloadId: b.workloadId,
status: b.status,
...(typeof b.contextType === 'string' ? { contextType: b.contextType } : {}),
...(b.workspace && typeof b.workspace === 'object' ? { workspace: b.workspace } : {}),
...(typeof b.revision === 'string' ? { revision: b.revision } : {}),
};
}

function asGrant(body: unknown): UploadGrant {
const g = body as Partial<UploadGrant> | null;
if (
!g ||
typeof g.uploadUrl !== 'string' ||
typeof g.token !== 'string' ||
!g.token ||
typeof g.expiresAt !== 'string' ||
typeof g.maxBytes !== 'number' ||
!Number.isSafeInteger(g.maxBytes) ||
g.maxBytes <= 0
) {
throw invalid('upload grant');
}
return {
uploadUrl: g.uploadUrl,
token: g.token,
expiresAt: g.expiresAt,
method: 'PUT',
contentType: typeof g.contentType === 'string' ? g.contentType : CONTEXT_MEDIA_TYPE,
maxBytes: g.maxBytes,
};
}
Loading
Loading