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
26 changes: 26 additions & 0 deletions apps/local-server/src/constellation-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,32 @@ export function registerConstellationRoutes(
},
);

server.post(
"/constellation/reset",
{
preValidation: strictBody(envelope, [
"worldInstanceId",
"expectedRevision",
"idempotencyKey",
]),
schema: {
tags,
summary: "End the current roster and start an empty World",
body: identityBody,
},
},
async (request, reply) => {
try {
return envelope.success(
request,
await service.reset(request.body as MutationBody),
);
} catch (error) {
return fail(error, request, reply, envelope);
}
},
);

server.post(
"/constellation/end",
{
Expand Down
194 changes: 116 additions & 78 deletions apps/local-server/src/constellation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,105 +654,143 @@ export class ConstellationService {
}

end(request: MutationIdentity): Promise<ConstellationState> {
return this.#enqueue(() => this.#end(request));
}

reset(request: MutationIdentity): Promise<ConstellationState> {
return this.#enqueue(async () => {
validateMutation(request);
const payload = this.#requirePayload(request.worldInstanceId);
const inputHash = sha256(canonical(request));
const existing = payload.idempotency.find(
(record) => record.key === request.idempotencyKey,
const prior = this.#payload?.idempotency.find(
(record) =>
record.key === request.idempotencyKey &&
record.operation === "world-reset",
);
if (existing) {
if (
existing.operation !== "world-end" ||
existing.inputHash !== inputHash
)
if (prior) {
if (prior.inputHash !== inputHash)
throw new ConstellationServiceError(
"conflict",
"Idempotency identity was reused with different input",
"Reset identity was reused with different input",
);
if (existing.status === "completed" && existing.result)
return structuredClone(existing.result);
} else {
this.#assertRevision(request.expectedRevision);
this.#setIdempotency({
key: request.idempotencyKey,
operation: "world-end",
inputHash,
status: "pending",
result: null,
});
this.#payload = {
...this.#requirePayload(request.worldInstanceId),
projection: ConstellationProjectionSchema.parse({
...payload.projection,
lifecycle: "ending",
revision: payload.projection.revision + 1,
entryReady: false,
truth: "current",
}),
};
await this.#persist();
if (prior.result) return structuredClone(prior.result);
}

const current = this.#requirePayload(request.worldInstanceId);
for (const agent of current.projection.agents) {
const prior = current.terminalOutcomes.find(
(outcome) => outcome.rosterId === agent.rosterId,
);
if (agent.sessionOwnership === "operator-persistent") {
if (!prior)
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "skipped-operator-persistent",
});
continue;
}
if (prior?.status === "ended") continue;
try {
await this.#lifecycle.endWorldSession(
agent.worldSessionId,
request.worldInstanceId,
);
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "ended",
});
} catch {
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "failed",
});
throw new ConstellationServiceError(
"upstream",
"A World-owned session could not be ended",
);
}
}

const finishing = this.#requirePayload(request.worldInstanceId);
await this.#end(request);
this.#payload = {
...finishing,
projection: ConstellationProjectionSchema.parse({
...finishing.projection,
lifecycle: "ended",
revision: finishing.projection.revision + 1,
entryReady: false,
truth: "current",
}),
projection: emptyProjection(randomUUID()),
terminalOutcomes: [],
idempotency: [],
};
const result = this.#currentState();
this.#setIdempotency({
key: request.idempotencyKey,
operation: "world-end",
operation: "world-reset",
inputHash,
status: "completed",
result,
});
await this.#persist();
return this.#currentState();
return result;
});
}

async #end(request: MutationIdentity): Promise<ConstellationState> {
validateMutation(request);
const payload = this.#requirePayload(request.worldInstanceId);
const inputHash = sha256(canonical(request));
const existing = payload.idempotency.find(
(record) => record.key === request.idempotencyKey,
);
if (existing) {
if (
existing.operation !== "world-end" ||
existing.inputHash !== inputHash
)
throw new ConstellationServiceError(
"conflict",
"Idempotency identity was reused with different input",
);
if (existing.status === "completed" && existing.result)
return structuredClone(existing.result);
} else {
this.#assertRevision(request.expectedRevision);
this.#setIdempotency({
key: request.idempotencyKey,
operation: "world-end",
inputHash,
status: "pending",
result: null,
});
this.#payload = {
...this.#requirePayload(request.worldInstanceId),
projection: ConstellationProjectionSchema.parse({
...payload.projection,
lifecycle: "ending",
revision: payload.projection.revision + 1,
entryReady: false,
truth: "current",
}),
};
await this.#persist();
}

const current = this.#requirePayload(request.worldInstanceId);
for (const agent of current.projection.agents) {
const prior = current.terminalOutcomes.find(
(outcome) => outcome.rosterId === agent.rosterId,
);
if (agent.sessionOwnership === "operator-persistent") {
if (!prior)
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "skipped-operator-persistent",
});
continue;
}
if (prior?.status === "ended") continue;
try {
await this.#lifecycle.endWorldSession(
agent.worldSessionId,
request.worldInstanceId,
);
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "ended",
});
} catch {
await this.#recordOutcome({
rosterId: agent.rosterId,
status: "failed",
});
throw new ConstellationServiceError(
"upstream",
"A World-owned session could not be ended",
);
}
}

const finishing = this.#requirePayload(request.worldInstanceId);
this.#payload = {
...finishing,
projection: ConstellationProjectionSchema.parse({
...finishing.projection,
lifecycle: "ended",
revision: finishing.projection.revision + 1,
entryReady: false,
truth: "current",
}),
};
const result = this.#currentState();
this.#setIdempotency({
key: request.idempotencyKey,
operation: "world-end",
inputHash,
status: "completed",
result,
});
await this.#persist();
return this.#currentState();
}

#mutate(
operation: string,
request: MutationIdentity,
Expand Down
111 changes: 111 additions & 0 deletions apps/local-server/test/phase19-constellation-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,117 @@ async function serverWith(
}

describe("Phase 19 constellation API", () => {
it("resets a full roster into a durable fresh World and permits the same agent choices", async () => {
const endWorldSession = vi.fn(async () => undefined);
const stateDirectory = directory();
const { server, service } = await serverWith(
lifecyclePort({ endWorldSession }),
stateDirectory,
);
const adapters = ["hermes", "openclaw", "codex", "claude-code"] as const;
for (const [index, adapter] of adapters.entries()) {
expect(
(
await server.inject({
method: "POST",
url: "/constellation/agents",
payload: agent(index + 1, adapter),
})
).statusCode,
).toBe(201);
}
const payload = identity(4, "reset-full-world");
const reset = await server.inject({
method: "POST",
url: "/constellation/reset",
payload,
});
expect(reset.statusCode).toBe(200);
const fresh = reset.json().data;
expect(fresh.projection).toMatchObject({
agents: [],
revision: 0,
lifecycle: "assembling",
entryReady: false,
});
expect(fresh.projection.worldInstanceId).not.toBe("world-api");
expect(endWorldSession.mock.calls).toEqual([
["api-world-session-2", "world-api"],
["api-world-session-3", "world-api"],
["api-world-session-4", "world-api"],
]);
for (const [index, adapter] of adapters.entries()) {
const input = agent(index + 1, adapter);
expect(
(
await server.inject({
method: "POST",
url: "/constellation/agents",
payload: {
...input,
worldInstanceId: fresh.projection.worldInstanceId,
agent: {
...input.agent,
worldSessionId: `replacement-${index}`,
nativeRootSessionRef: `replacement-native-${index}`,
},
},
})
).statusCode,
).toBe(201);
}
const replay = await server.inject({
method: "POST",
url: "/constellation/reset",
payload,
});
expect(replay.statusCode).toBe(200);
expect(replay.json().data).toEqual(fresh);
expect(service.current().projection?.agents).toHaveLength(4);
expect(endWorldSession).toHaveBeenCalledTimes(3);
const reopened = await ConstellationService.open({
directory: stateDirectory,
lifecycle: lifecyclePort(),
});
expect(reopened.current().projection?.worldInstanceId).toBe(
fresh.projection.worldInstanceId,
);
expect(reopened.current().projection?.agents).toHaveLength(4);
});

it("keeps failed reset retryable without opening a replacement World", async () => {
const endWorldSession = vi
.fn()
.mockRejectedValueOnce(new Error("busy"))
.mockResolvedValue(undefined);
const { server, service } = await serverWith(
lifecyclePort({ endWorldSession }),
);
await server.inject({
method: "POST",
url: "/constellation/agents",
payload: agent(1, "codex"),
});
const payload = identity(1, "reset-retry");
const failed = await server.inject({
method: "POST",
url: "/constellation/reset",
payload,
});
expect(failed.statusCode).toBe(502);
expect(service.current().projection).toMatchObject({
worldInstanceId: "world-api",
lifecycle: "ending",
});
const retried = await server.inject({
method: "POST",
url: "/constellation/reset",
payload,
});
expect(retried.statusCode).toBe(200);
expect(retried.json().data.projection.agents).toEqual([]);
expect(endWorldSession).toHaveBeenCalledTimes(2);
});
it("exposes all six routes with normal envelopes and side-effect-free repeated GET", async () => {
const validateBinding = vi.fn(async (binding) => ({
...binding,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/sessions/session-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,12 @@ export class AgentSessionClient {
);
}

resetConstellation(
input: ConstellationMutation,
): Promise<ConstellationState> {
return this.post("/api/constellation/reset", input);
}

endConstellation(input: ConstellationMutation): Promise<ConstellationState> {
return this.post("/api/constellation/end", input);
}
Expand Down
Loading
Loading