diff --git a/.codex/skills/deploy-qm/SKILL.md b/.codex/skills/deploy-qm/SKILL.md index 3a470a227..d985222f0 100644 --- a/.codex/skills/deploy-qm/SKILL.md +++ b/.codex/skills/deploy-qm/SKILL.md @@ -20,4 +20,6 @@ only once the operator has chosen the broker. Use the installed `@yc-software/qm` dependency through `npm exec qm -- `. Do not require or clone the QM source repository. Complete every acceptance check -and return the handoff required by `deployment.md`. +and return the handoff required by `deployment.md`. Treat `qm check --live` and +its private live session canary as the automated release gate; still complete +the administrator's manual sign-in and web acceptance check. diff --git a/cli/package-lock.json b/cli/package-lock.json index 6a1229211..8b469f6a2 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yc-software/qm", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yc-software/qm", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "bin": { "qm": "dist/bin/qm.js" diff --git a/cli/package.json b/cli/package.json index d7349677a..4fbc357d4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.5", + "version": "0.1.6", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/cli/src/backends/aws.ts b/cli/src/backends/aws.ts index 9c5db74ba..7a1bd86a4 100644 --- a/cli/src/backends/aws.ts +++ b/cli/src/backends/aws.ts @@ -748,10 +748,75 @@ interface EcsServiceState { desiredCount?: number; runningCount?: number; taskDefinition?: string; + networkConfiguration?: { + awsvpcConfiguration?: { + subnets?: string[]; + securityGroups?: string[]; + assignPublicIp?: "ENABLED" | "DISABLED"; + }; + }; deployments?: EcsDeploymentState[]; tags?: Array<{ key?: string; value?: string }>; } +function awsLiveSession(config: QmConfig, core: EcsServiceState): void { + const aws = requireAws(config); + if (!core.taskDefinition) throw new Error("core service has no live task definition"); + if (!core.networkConfiguration?.awsvpcConfiguration) throw new Error("core service has no VPC network configuration"); + const started = awsJson<{ + tasks?: Array<{ taskArn?: string }>; + failures?: Array<{ arn?: string; reason?: string; detail?: string }>; + }>(aws, [ + "ecs", + "run-task", + "--cluster", + aws.cluster, + "--task-definition", + core.taskDefinition, + "--launch-type", + "FARGATE", + "--network-configuration", + JSON.stringify(core.networkConfiguration), + "--overrides", + JSON.stringify({ + containerOverrides: [ + { + name: "core", + command: [ + "node", + "src/deployment/postdeploy-smoke.ts", + "session", + `http://core.${aws.networking.cloudMapNamespace}:8080`, + ], + }, + ], + }), + "--count", + "1", + ]); + const taskArn = started.tasks?.[0]?.taskArn; + if (!taskArn) { + const failure = started.failures?.[0]; + throw new Error( + `could not start canary task: ${failure?.reason ?? failure?.detail ?? failure?.arn ?? "no task returned"}`, + ); + } + awsText(aws, ["ecs", "wait", "tasks-stopped", "--cluster", aws.cluster, "--tasks", taskArn]); + const stopped = awsJson<{ + tasks?: Array<{ + stoppedReason?: string; + containers?: Array<{ name?: string; exitCode?: number; reason?: string }>; + }>; + }>(aws, ["ecs", "describe-tasks", "--cluster", aws.cluster, "--tasks", taskArn]); + const task = stopped.tasks?.[0]; + const coreContainer = task?.containers?.find((container) => container.name === "core"); + if (coreContainer?.exitCode !== 0) { + throw new Error( + `canary task exited ${coreContainer?.exitCode ?? "without a code"}: ${coreContainer?.reason ?? task?.stoppedReason ?? "unknown reason"}`, + ); + } +} + type DeploymentImageProvenance = | { kind: "configured"; source: string } | { kind: "source-build"; source?: "plugin" | "checkout"; gitCommit?: string; dirty?: boolean }; @@ -3154,6 +3219,14 @@ async function checkLive( failures.push(`deployment layer drift: ${errMessage(error)}`); } } + if (!failures.length) { + try { + awsLiveSession(config, states.get("core")!); + if (opts.report ?? true) step("core: private live session smoke passed"); + } catch (error) { + failures.push(`core: private live session smoke failed: ${errMessage(error)}`); + } + } if (failures.length) throw new CliError(`live drift detected:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`, { clause: "aws.live-drift", diff --git a/cli/src/backends/fly.ts b/cli/src/backends/fly.ts index 0940eb03d..06d9580a4 100644 --- a/cli/src/backends/fly.ts +++ b/cli/src/backends/fly.ts @@ -397,6 +397,14 @@ function flyS3RoundTrip(app: string, machineId: string): void { fly(["ssh", "console", "-a", app, "--machine", machineId, "--command", flyS3ProbeCommand(), "--quiet"]); } +export function flyLiveSessionCommand(): string { + return "node src/deployment/postdeploy-smoke.ts session http://127.0.0.1:8080"; +} + +function flyLiveSession(app: string, machineId: string): void { + fly(["ssh", "console", "-a", app, "--machine", machineId, "--command", flyLiveSessionCommand(), "--quiet"]); +} + function flyOrgApps(flyOrg: string): Set { const raw = fly(["apps", "list", "--org", flyOrg, "--json"]); let parsed: unknown; @@ -1615,6 +1623,18 @@ export async function flyCheckLive( } catch (error) { failures.push(`${healthUrl}: ${errMessage(error)}`); } + if (!failures.length) { + if (!coreMachineId) { + failures.push(`${ctx.appPrefix}-core: cannot run the live session smoke without an identified machine`); + } else { + try { + flyLiveSession(`${ctx.appPrefix}-core`, coreMachineId); + if (report) step(`${ctx.appPrefix}-core: private live session smoke passed`); + } catch (error) { + failures.push(`${ctx.appPrefix}-core: private live session smoke failed: ${errMessage(error)}`); + } + } + } if (failures.length) { throw new CliError(`live check failed:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`, { clause: "fly.live-readiness", diff --git a/cli/templates/aws/main.tf b/cli/templates/aws/main.tf index ea83f5e2c..792d950b3 100644 --- a/cli/templates/aws/main.tf +++ b/cli/templates/aws/main.tf @@ -345,6 +345,21 @@ resource "aws_iam_role_policy" "github_deploy" { Action = ["ecs:DescribeServices", "ecs:ListTagsForResource", "ecs:UpdateService"] Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:service/${var.cluster_name}/*"] }, + { + Sid = "RunDeploymentCanaries" + Effect = "Allow" + Action = ["ecs:RunTask"] + Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task-definition/${var.services["core"].ecs_service}:*"] + Condition = { + ArnEquals = { "ecs:cluster" = aws_ecs_cluster.this.arn } + } + }, + { + Sid = "InspectDeploymentCanaries" + Effect = "Allow" + Action = ["ecs:DescribeTasks"] + Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*"] + }, { Sid = "PassTaskRolesToEcs" Effect = "Allow" @@ -606,7 +621,7 @@ resource "aws_iam_role_policy" "task_objects" { Resource = aws_s3_bucket.objects.arn }, { - Effect = "Allow" + Effect = "Allow" # AbortMultipartUpload is its own action — PutObject covers Create/UploadPart/Complete but # not the abort, and without it a failed staging upload strands parts that bill silently. Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"] diff --git a/cli/templates/deployment/SKILL.md b/cli/templates/deployment/SKILL.md index 055743be7..7c54ca764 100644 --- a/cli/templates/deployment/SKILL.md +++ b/cli/templates/deployment/SKILL.md @@ -21,4 +21,5 @@ only once the operator has chosen the broker. Use the repository's installed `@yc-software/qm` dependency through `npm exec qm -- `. Do not require or clone the QM source repository. Do not stop at infrastructure health: complete the acceptance checks and return -the handoff required by `deployment.md`. +the handoff required by `deployment.md`. A web response without a generated +sidebar title is not a completed deployment. diff --git a/cli/templates/deployment/deployment.md b/cli/templates/deployment/deployment.md index cc33e60e2..857129e32 100644 --- a/cli/templates/deployment/deployment.md +++ b/cli/templates/deployment/deployment.md @@ -4,9 +4,10 @@ This repository defines one QM deployment. The `@yc-software/qm` dependency supp the deployment engine; this repository owns the organization-specific config, sandbox layer, provider coordinates, and generated Slack manifests. -The task is complete only after the administrator can sign in, receive a real -web response, and, when Slack is requested, mention the bot in a test channel -and receive a response. +The automated release gate is `qm check --live`, including its private live +session canary. The task is complete only after that gate passes, the +administrator can sign in and receive a real web response, and, when Slack is +requested, the bot replies in a test channel. ## 1. Collect choices and authorization @@ -212,6 +213,14 @@ npm exec qm -- conformance npm exec qm -- outputs --json ``` +`check --live` verifies provider infrastructure, private storage, public +health, and a private end-to-end web session. The session canary runs one real +agent turn plus auxiliary title generation, verifies the exact reply and +persisted transcript, requires a generated title, checks the session-scoped +error log, and archives itself. It does not recall or capture administrator +memory. Fly runs it inside the core machine; AWS runs it as a one-off task on +the core service's private network. It does not add a public session endpoint. + Open `adminOnboardingUrl` from the JSON output and confirm Model provider reports the chosen vendor as configured, sourced from the environment. It does when `modelProvider` is set: the key travelled with the rest of the deployment @@ -227,7 +236,10 @@ place a deployment key belongs, and `qm secrets push` moves it without printing it. Open `webUiUrl`, sign in as the seeded administrator, send a message, and -receive a real model response. Ask the agent to create a fresh UUID in +receive a real model response. Use a specific request rather than a greeting, +then confirm its generated sidebar title replaces the `Web chat` fallback. A +missing title is one failed runtime assertion; inspect the core error log and +rerun `check --live` before continuing. Ask the agent to create a fresh UUID in `/root/workspace/qm-computer-proof.txt`, then use the provider reference's independent proof to verify that UUID outside the model transcript. @@ -267,9 +279,9 @@ Return: - provider, account or organization, and region; - the base model provider and where its key lives — the deployment `.env` or the Admin page — so the operator knows what to rotate and where; -- pass/fail for health, sign-in, web chat, agent-computer proof, connector - visibility, user OAuth, Slack reply, live check, conformance, and an - idempotent deployment rerun; +- pass/fail for health, the private live session canary, sign-in, manual web + chat and generated title, agent-computer proof, connector visibility, user + OAuth, Slack reply, conformance, and an idempotent deployment rerun; - `npm exec qm -- status`, logs, rollback, and teardown commands; - recurring cost or manual work still owned by the operator, including model usage billed directly by the provider. diff --git a/cli/templates/deployment/references/aws.md b/cli/templates/deployment/references/aws.md index 5966b38d5..c46067ac9 100644 --- a/cli/templates/deployment/references/aws.md +++ b/cli/templates/deployment/references/aws.md @@ -46,6 +46,12 @@ npm exec qm -- up --yes npm exec qm -- check --live ``` +Existing deployments created before private session canaries must rerun +`npm exec qm -- infra render`, review the Terraform plan, and apply it with +infrastructure-administrator credentials before enabling `check --live`. This +adds the deploy role's stack-scoped permission to run and inspect the one-off +core canary task. + The package image manifest supplies first-party control-plane images. The AWS backend transfers them into deployment-owned ECR and records immutable digests. After the first successful deployment, rerun `npm exec qm -- up --yes` and diff --git a/cli/test/aws.test.ts b/cli/test/aws.test.ts index 8f045e892..4074b5130 100644 --- a/cli/test/aws.test.ts +++ b/cli/test/aws.test.ts @@ -266,9 +266,12 @@ else if (a.includes("ecs describe-services")) { : ${JSON.stringify(opts.primaryFailedTasks ?? false)} || transientlyFailing ? [{ id: service.deploymentId, status: "PRIMARY", taskDefinition: service.taskDefinition, rolloutState: "IN_PROGRESS", runningCount: 0, failedTasks: 1 }] : [{ id: service.deploymentId, status: "PRIMARY", taskDefinition: service.taskDefinition, rolloutState: "COMPLETED", runningCount: service.desiredCount, failedTasks: transientFailedTaskPolls && s.updated ? 1 : 0 }]; - return [{ serviceName: name, status: "ACTIVE", desiredCount: service.desiredCount, runningCount: ${JSON.stringify(opts.drainRollout ?? false)} ? service.desiredCount + 1 : service.desiredCount, taskDefinition: service.taskDefinition, deployments, loadBalancers: service.workload === ${JSON.stringify(frontService)} ? [{ targetGroupArn: ${JSON.stringify(frontTargetArn)} }] : (service.workload === "core" && ${JSON.stringify(coreHosts.length > 0)} ? [{ targetGroupArn: ${JSON.stringify(coreTargetArn)} }] : []), tags: [{ key: "Deployment", value: ${JSON.stringify(opts.foreignServiceTags ? "other" : configured.orgId)} }, { key: "ManagedBy", value: "terraform" }] }]; + return [{ serviceName: name, status: "ACTIVE", desiredCount: service.desiredCount, runningCount: ${JSON.stringify(opts.drainRollout ?? false)} ? service.desiredCount + 1 : service.desiredCount, taskDefinition: service.taskDefinition, networkConfiguration: { awsvpcConfiguration: { subnets: ["subnet-test"], securityGroups: ["sg-test"], assignPublicIp: "DISABLED" } }, deployments, loadBalancers: service.workload === ${JSON.stringify(frontService)} ? [{ targetGroupArn: ${JSON.stringify(frontTargetArn)} }] : (service.workload === "core" && ${JSON.stringify(coreHosts.length > 0)} ? [{ targetGroupArn: ${JSON.stringify(coreTargetArn)} }] : []), tags: [{ key: "Deployment", value: ${JSON.stringify(opts.foreignServiceTags ? "other" : configured.orgId)} }, { key: "ManagedBy", value: "terraform" }] }]; }), failures: names.filter((name) => !s.services[name]).map((name) => ({ arn: name, reason: "MISSING" })) })); } +else if (a.includes("ecs run-task")) console.log(JSON.stringify({ tasks: [{ taskArn: "arn:aws:ecs:us-west-2:123456789012:task/canary" }] })); +else if (a.includes("ecs wait tasks-stopped")) console.log(""); +else if (a.includes("ecs describe-tasks")) console.log(JSON.stringify({ tasks: [{ stoppedReason: "Essential container exited", containers: [{ name: "core", exitCode: Number(process.env.AWS_FAKE_CANARY_EXIT || "0"), reason: process.env.AWS_FAKE_CANARY_REASON }] }] })); else if (a.includes("ecs describe-task-definition")) { const id = after("--task-definition"); console.log(JSON.stringify({ taskDefinition: s.definitions[id] })); @@ -1010,6 +1013,7 @@ test("AWS up scales services to the configured desired count and live check flag }; const fake = statefulAws(dir, scaled()); const priorPath = process.env.PATH; + const priorCanaryExit = process.env.AWS_FAKE_CANARY_EXIT; process.env.PATH = `${dir}:${priorPath}`; try { await awsUp(scaled(), dir, { yes: true }); @@ -1017,6 +1021,17 @@ test("AWS up scales services to the configured desired count and live check flag assert.equal(state.services["acme-core"].desiredCount, 2); assert.match(readFileSync(fake.log, "utf8"), /ecs update-service .*--desired-count 2/); await assert.doesNotReject(() => awsCheckLive(scaled(), { report: false })); + assert.match( + readFileSync(fake.log, "utf8"), + /ecs run-task .*postdeploy-smoke\.ts.*session.*http:\/\/core\.acme\.internal:8080/, + ); + process.env.AWS_FAKE_CANARY_EXIT = "1"; + await assert.rejects( + () => awsCheckLive(scaled(), { report: false }), + /core: private live session smoke failed: canary task exited 1/, + ); + if (priorCanaryExit === undefined) delete process.env.AWS_FAKE_CANARY_EXIT; + else process.env.AWS_FAKE_CANARY_EXIT = priorCanaryExit; state.services["acme-core"].desiredCount = 1; writeFileSync(fake.state, JSON.stringify(state)); await assert.rejects( @@ -1024,6 +1039,8 @@ test("AWS up scales services to the configured desired count and live check flag /core: runtime is ACTIVE with 1\/1 running, expected 2/, ); } finally { + if (priorCanaryExit === undefined) delete process.env.AWS_FAKE_CANARY_EXIT; + else process.env.AWS_FAKE_CANARY_EXIT = priorCanaryExit; process.env.PATH = priorPath; fake.restore(); rmSync(dir, { recursive: true, force: true }); diff --git a/cli/test/cli-dispatch.test.ts b/cli/test/cli-dispatch.test.ts index f3a988d45..d5b2bf131 100644 --- a/cli/test/cli-dispatch.test.ts +++ b/cli/test/cli-dispatch.test.ts @@ -281,8 +281,10 @@ else if (args.includes("lambda-microvms get-microvm-image")) console.log(JSON.st else if (args.includes("lambda-microvms list-microvm-image-versions")) console.log(JSON.stringify({ items: [{ imageVersion: "1", state: "SUCCESSFUL", status: "ACTIVE" }] })); else if (args.includes("get-secret-value") && args.includes("--query SecretString")) console.log("signing-secret".repeat(3)); else if (args.includes("get-secret-value")) console.log(JSON.stringify({ ARN: "arn", SecretString: "secret-value".repeat(3) })); -else if (args.includes("describe-services")) console.log(JSON.stringify({ services: [{ serviceName: "s", status: "ACTIVE", desiredCount: 1, runningCount: 1, taskDefinition: "task", deployments: [{ status: "PRIMARY", rolloutState: "COMPLETED", taskDefinition: "task" }], loadBalancers: [{ targetGroupArn: "tg" }] }] })); +else if (args.includes("describe-services")) console.log(JSON.stringify({ services: [{ serviceName: "s", status: "ACTIVE", desiredCount: 1, runningCount: 1, taskDefinition: "task", networkConfiguration: { awsvpcConfiguration: { subnets: ["subnet"], securityGroups: ["sg"], assignPublicIp: "DISABLED" } }, deployments: [{ status: "PRIMARY", rolloutState: "COMPLETED", taskDefinition: "task" }], loadBalancers: [{ targetGroupArn: "tg" }] }] })); else if (args.includes("describe-task-definition")) console.log(${JSON.stringify(JSON.stringify({ taskDefinition: task }))}); +else if (args.includes("run-task")) console.log(JSON.stringify({ tasks: [{ taskArn: "canary" }] })); +else if (args.includes("describe-tasks")) console.log(JSON.stringify({ tasks: [{ containers: [{ name: "core", exitCode: 0 }] }] })); else if (args.includes("dynamodb get-item") && args.includes("deployment/current")) console.log(JSON.stringify({ Item: { manifestId: { S: "manifest" } } })); else if (args.includes("dynamodb get-item") && args.includes("deployment/manifest/manifest")) console.log(JSON.stringify({ Item: { manifest: { S: ${JSON.stringify(JSON.stringify(manifest))} } } })); else if (args.includes("s3api get-object")) fs.writeFileSync(argv[argv.indexOf("--key") + 2], ${JSON.stringify(layerBody)}); diff --git a/cli/test/fly-sandbox.test.ts b/cli/test/fly-sandbox.test.ts index 4d1d7f28c..104c71ec6 100644 --- a/cli/test/fly-sandbox.test.ts +++ b/cli/test/fly-sandbox.test.ts @@ -7,11 +7,22 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { loadConfigAt, type QmConfig } from "../src/config.ts"; -import { derivedTomlFor, derivedPluginTomlFor, flyCheckLive, flyS3ProbeCommand, flyUp } from "../src/backends/fly.ts"; +import { + derivedTomlFor, + derivedPluginTomlFor, + flyCheckLive, + flyLiveSessionCommand, + flyS3ProbeCommand, + flyUp, +} from "../src/backends/fly.ts"; import type { ResolvedPlugin } from "../src/plugins.ts"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +test("Fly runs the live session smoke inside the private core machine", () => { + assert.equal(flyLiveSessionCommand(), "node src/deployment/postdeploy-smoke.ts session http://127.0.0.1:8080"); +}); + test("a fly config's sandbox block rewrites the core fly.toml [env] (app, image, env literals)", () => { const { config } = loadConfigAt(join(repoRoot, "deploy", "stacks", "acme", "qm.config.jsonc")); const cfg = { diff --git a/cli/test/terraform.test.ts b/cli/test/terraform.test.ts index bb52ea455..5ff16169d 100644 --- a/cli/test/terraform.test.ts +++ b/cli/test/terraform.test.ts @@ -83,6 +83,20 @@ test("the deploy role registers task definitions only for configured ECS familie assert.doesNotMatch(global, /ecs:ListTagsForResource/); }); +test("the deploy role can run and inspect only stack-scoped deployment canaries", () => { + const policy = mainTf.match(/resource "aws_iam_role_policy" "github_deploy" \{([\s\S]*?)\n\}/)?.[1] ?? ""; + const run = policy.match(/Sid\s*= "RunDeploymentCanaries"([\s\S]*?)\n\s*\},/)?.[1] ?? ""; + assert.match(run, /ecs:RunTask/); + assert.match(run, /task-definition\/\$\{var\.services\["core"\]\.ecs_service\}:\*/); + assert.match(run, /"ecs:cluster"\s*=\s*aws_ecs_cluster\.this\.arn/); + assert.doesNotMatch(run, /Resource\s*= "\*"/); + + const inspect = policy.match(/Sid\s*= "InspectDeploymentCanaries"([\s\S]*?)\n\s*\},/)?.[1] ?? ""; + assert.match(inspect, /ecs:DescribeTasks/); + assert.match(inspect, /task\/\$\{var\.cluster_name\}\/\*/); + assert.doesNotMatch(inspect, /Resource\s*= "\*"/); +}); + test("AWS deployments retain recovery history and can create scoped predeploy snapshots", () => { const variables = readFileSync(new URL("../templates/aws/variables.tf", import.meta.url), "utf8"); assert.match(variables, /variable "db_backup_retention_days" \{[\s\S]*default = 35/); diff --git a/src/admin/error-log.ts b/src/admin/error-log.ts index 5408ce29d..3540b280a 100644 --- a/src/admin/error-log.ts +++ b/src/admin/error-log.ts @@ -12,6 +12,7 @@ export interface ErrorEvent { export interface ErrorLog { record(e: Omit): void; + flush(): Promise; list(opts?: { scopeId?: string; sessionId?: string; limit?: number }): Promise; count(opts?: { scopeId?: string; sessionId?: string }): Promise; } @@ -22,6 +23,7 @@ export function createErrorLog(): ErrorLog { const sink = createTimestampedEventSink({ max: MAX, defaultLimit: 200, equalityFields: ["sessionId"] }); return { record: sink.record, + flush: async () => {}, list: (opts = {}) => sink.list(opts), count: async (opts = {}) => (await sink.list({ ...opts, limit: MAX })).length, }; diff --git a/src/admin/scoped-event-sink.ts b/src/admin/scoped-event-sink.ts index 8b86b4a8f..25f1dc3ac 100644 --- a/src/admin/scoped-event-sink.ts +++ b/src/admin/scoped-event-sink.ts @@ -102,6 +102,7 @@ export interface PostgresEventSinkConfig { export interface PostgresEventSink { q: PgPool["q"]; record(input: Omit): void; + flush(): Promise; list(opts?: object): Promise; count(opts?: object): Promise; } @@ -129,6 +130,8 @@ export function createPostgresEventSink(cfg: PostgresEventSinkConfig): Pos const dbCols = cfg.columns.map(([db]) => db).join(", "); const insertSql = `INSERT INTO ${cfg.table}(${dbCols}) VALUES (${cfg.columns.map((_, i) => `$${i + 1}`).join(",")})`; + const pendingWrites = new Set>(); + const settleWrites = (): Promise => Promise.all(pendingWrites); const toEvent = (r: Record): E => { const out: Record = {}; @@ -158,9 +161,16 @@ export function createPostgresEventSink(cfg: PostgresEventSinkConfig): Pos record(input) { const s = input as Record; const values = cfg.columns.map(([, js]) => (js === "ts" ? Date.now() : (s[js] ?? null))); - void q(insertSql, values).catch((err) => console.error(cfg.persistErrorMessage, err)); + const write = q(insertSql, values) + .catch((err) => console.error(cfg.persistErrorMessage, err)) + .finally(() => pendingWrites.delete(write)); + pendingWrites.add(write); + }, + async flush() { + await settleWrites(); }, async list(input = {}) { + await settleWrites(); const opts = input as Record; const { where, params } = buildWhere(opts); params.push(opts.limit ?? cfg.defaultLimit); @@ -171,6 +181,7 @@ export function createPostgresEventSink(cfg: PostgresEventSinkConfig): Pos return rows.map(toEvent); }, async count(input = {}) { + await settleWrites(); const { where, params } = buildWhere(input as Record); const rows = await q(`SELECT COUNT(*)::bigint AS total FROM ${cfg.table} ${where}`, params); return Number(rows[0]?.total ?? 0); diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts index 17d835f0e..a038be37b 100644 --- a/src/api/app-turn.ts +++ b/src/api/app-turn.ts @@ -240,6 +240,7 @@ export function createTurnMethods( ...(req.model ? { model: req.model } : {}), ...turnModelOptions(req), ...(req.readOnly ? { readOnly: true } : {}), + ...(req.skipMemory ? { skipMemory: true } : {}), ...(req.surfaceTools ? { surfaceTools: true } : {}), ...(req.envelopeWrapped ? { envelopeWrapped: true } : {}), ...(typeof req.displayText === "string" && req.displayText ? { displayText: req.displayText } : {}), diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts index 373b496ad..cadd8abae 100644 --- a/src/core/orchestrator.ts +++ b/src/core/orchestrator.ts @@ -747,16 +747,17 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { }; } const strictReadOnly = input.readOnly === true; + const useMemory = input.skipMemory !== true; const environmentId = await resolveEnvironmentId(deps.environments, scopeId); const rwLayer = resolution.layers.find((l) => l.mode === "rw"); if (rwLayer && environmentId !== rwLayer.scopeId) rwLayer.scopeId = environmentId; for (const layer of resolution.layers) await deps.workspace.ensureScope(layer.scopeId); const memoryScopeId = writableMemoryScope(resolution.layers, scopeId); - const recallScopes = recallMemoryScopes(memoryPolicy, resolution.layers, memoryScopeId); + const recallScopes = useMemory ? recallMemoryScopes(memoryPolicy, resolution.layers, memoryScopeId) : []; const memoryAccess = - memoryPolicy.capture !== "off" || recallScopes.length > 0 - ? { ...(memoryPolicy.capture !== "off" ? { write: memoryScopeId } : {}), read: recallScopes } + (useMemory && memoryPolicy.capture !== "off") || recallScopes.length > 0 + ? { ...(useMemory && memoryPolicy.capture !== "off" ? { write: memoryScopeId } : {}), read: recallScopes } : undefined; const skillScopes = visibleSkillScopes(resolution, scopeId); const recalledSections: string[] = []; @@ -803,7 +804,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { .profileFor(memoryScopeId) .catch(swallowAs("orchestrator: scope profile read", deps.sandbox.profile)) : deps.sandbox.profile; - const strategyLines = memoryStrategy.promptLines?.() ?? []; + const strategyLines = useMemory ? (memoryStrategy.promptLines?.() ?? []) : []; if (strategyLines.length) { systemPrompt += `\n\n${strategyLines.join("\n")}`; } @@ -876,7 +877,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { : ""; let onboardingBlock = ""; - if (conversation.kind === "dm" && onboardingSkillVisible(visibleSkills)) { + if (useMemory && conversation.kind === "dm" && onboardingSkillVisible(visibleSkills)) { const fullMemory = await deps.memory.read(memoryScopeId).catch(swallowAs("orchestrator: memory read", "")); onboardingBlock = renderPendingOnboardingPrompt(detectOnboardingStatus(fullMemory)); } @@ -1032,7 +1033,12 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { .adminStatusOf(actor) .catch(swallowAs("orchestrator: admin status for turn", { isAdmin: false })); actorIsOrgAdmin = status.isAdmin; - if (actorIsOrgAdmin && memoryPolicy.capture !== "off" && resolution.orgScopeId !== memoryScopeId) { + if ( + actorIsOrgAdmin && + useMemory && + memoryPolicy.capture !== "off" && + resolution.orgScopeId !== memoryScopeId + ) { orgMemoryWrite = resolution.orgScopeId; } } @@ -2589,7 +2595,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { : {}), }); const onTurnEnd = memoryStrategy.onTurnEnd?.bind(memoryStrategy); - if (!pausing && memoryPolicy.capture !== "off" && onTurnEnd) { + if (!pausing && useMemory && memoryPolicy.capture !== "off" && onTurnEnd) { const prior = pendingCaptures.get(memoryScopeId); const capture = (async () => { if (prior) await prior.catch(swallowAs("prior memory capture", undefined)); @@ -2763,6 +2769,7 @@ export function createOrchestrator(deps: OrchestratorDeps): Orchestrator { await tail(); tailOwnsCleanup = true; } + await deps.errors?.flush(); return finalResult; } catch (err) { if (err instanceof ProjectRosterChanged) { diff --git a/src/core/orchestrator/turn-helpers.ts b/src/core/orchestrator/turn-helpers.ts index 452848b68..64e409479 100644 --- a/src/core/orchestrator/turn-helpers.ts +++ b/src/core/orchestrator/turn-helpers.ts @@ -256,6 +256,7 @@ export function replayableRequest(input: OrchestratorInput): TurnRequest { ...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}), ...(input.fastMode !== undefined ? { fastMode: input.fastMode } : {}), ...(input.readOnly ? { readOnly: true } : {}), + ...(input.skipMemory ? { skipMemory: true } : {}), ...(input.surfaceTools ? { surfaceTools: true } : {}), ...(input.addressed ? { addressed: true } : {}), ...(input.envelopeWrapped ? { envelopeWrapped: true } : {}), diff --git a/src/deployment/postdeploy-smoke.ts b/src/deployment/postdeploy-smoke.ts index 69c199238..211cf033b 100644 --- a/src/deployment/postdeploy-smoke.ts +++ b/src/deployment/postdeploy-smoke.ts @@ -4,6 +4,7 @@ import { PORTAL_IDENTITY_HEADER } from "../auth/portal-identity.ts"; import { mintSignedPayload } from "../auth/signed-token.ts"; import { signedRequestHeaders } from "../auth/source-auth-sign.ts"; import { loadConfig, type Config } from "../config.ts"; +import { errMessage } from "../util/errors.ts"; export const PARALLEL_EXCEPTION_QUERY = ` SELECT n.nspname AS schema_name, p.proname AS function_name @@ -108,6 +109,113 @@ export async function stagingApiHeaders( ); } +type LiveSessionConfig = Pick; + +export async function checkLiveSession( + config: LiveSessionConfig, + baseUrl: string, + fetchImpl: FetchLike = fetch, +): Promise { + const { orgId, portalIdentitySecret, signingSecret: sourceSecret } = config; + if (!orgId) throw new Error("live session smoke requires ORG_ID"); + if (!sourceSecret) throw new Error("live session smoke requires CORE_SIGNING_SECRET"); + if (!portalIdentitySecret) throw new Error("live session smoke requires PORTAL_IDENTITY_SECRET"); + const principalId = firstAdminPrincipal(config.adminGrants); + const root = baseUrl.replace(/\/+$/, ""); + const request = async (method: "GET" | "POST", path: string, body?: unknown, admin = false): Promise => { + const raw = body === undefined ? "" : JSON.stringify(body); + const headers = admin + ? await stagingApiHeaders(orgId, principalId, sourceSecret, portalIdentitySecret, path) + : signedRequestHeaders(sourceSecret, method, path, raw); + const response = await fetchImpl(`${root}${path}`, { + method, + headers: { ...headers, ...(raw ? { "content-type": "application/json" } : {}) }, + ...(raw ? { body: raw } : {}), + }); + const text = await response.text(); + if (!response.ok) + throw new Error(`live session ${method} ${path} returned ${response.status}: ${text.slice(0, 500)}`); + try { + return JSON.parse(text) as unknown; + } catch { + throw new Error(`live session ${method} ${path} returned unparseable JSON`); + } + }; + + const nonce = randomUUID(); + const expectedReply = "QM deployment canary passed."; + const threadRef = `web:${principalId}:deployment-canary-${nonce}`; + let turn: { status?: string; sessionId?: string; reply?: string } | undefined; + let failure: unknown; + try { + turn = (await request("POST", "/v1/turns", { + surface: "web", + actor: { externalId: principalId }, + conversation: { kind: "dm", threadRef }, + text: `Reply with exactly: ${expectedReply}`, + origin: { kind: "human" }, + addressed: true, + readOnly: true, + skipMemory: true, + idempotencyKey: nonce, + })) as { status?: string; sessionId?: string; reply?: string }; + if (!turn.sessionId) throw new Error(`live session model turn failed with status ${turn.status ?? "missing"}`); + if (turn.status !== "ok") throw new Error(`live session model turn failed with status ${turn.status ?? "missing"}`); + if (turn.reply?.trim() !== expectedReply) throw new Error("live session received an unexpected model reply"); + + const sessionPath = `/v1/sessions/${encodeURIComponent(turn.sessionId)}?viewer=${encodeURIComponent(principalId)}&tailTurns=1`; + const persisted = (await request("GET", sessionPath)) as { + session?: { title?: string | null }; + entries?: Array<{ type?: string }>; + }; + if (!persisted.session?.title?.trim()) throw new Error("live session has no generated title"); + if (!persisted.entries?.some((entry) => entry.type === "user")) + throw new Error("live session has no persisted user turn"); + if (!persisted.entries.some((entry) => entry.type === "assistant")) { + throw new Error("live session has no persisted assistant turn"); + } + + const errorsPath = `/v1/admin/errors?scope=${encodeURIComponent(`personal:${principalId}`)}&sessionId=${encodeURIComponent(turn.sessionId)}`; + const logged = (await request("GET", errorsPath, undefined, true)) as { errors?: unknown[] }; + if (!Array.isArray(logged.errors)) throw new Error("live session error log response has no errors array"); + if (logged.errors.length) throw new Error(`live session recorded ${logged.errors.length} error event(s)`); + } catch (error) { + failure = error; + } + let sessionId = turn?.sessionId; + if (!sessionId) { + try { + const sessionsPath = `/v1/admin/sessions?scope=${encodeURIComponent(`personal:${principalId}`)}&category=all&limit=200`; + const listed = (await request("GET", sessionsPath, undefined, true)) as { + sessions?: Array<{ id?: string; threadRef?: string }>; + }; + sessionId = listed.sessions?.find((session) => session.threadRef === threadRef)?.id; + } catch (error) { + if (failure) + throw new AggregateError( + [failure, error], + `${errMessage(failure)}; session recovery failed: ${errMessage(error)}`, + { cause: error }, + ); + throw error; + } + } + if (sessionId) { + try { + await request("POST", `/v1/sessions/${encodeURIComponent(sessionId)}`, { principalId, archived: true }); + } catch (error) { + if (failure) + throw new AggregateError( + [failure, error], + `${errMessage(failure)}; session archive failed: ${errMessage(error)}`, + { cause: error }, + ); + throw error; + } + } + if (failure) throw failure; +} + async function checkApi( orgId: string, principalId: string, @@ -172,5 +280,11 @@ async function runPostdeploySmoke(config: PostdeployConfig): Promise { } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await runPostdeploySmoke(loadConfig()); + const config = loadConfig(); + if (process.argv[2] === "session") { + await checkLiveSession(config, process.argv[3] ?? `http://127.0.0.1:${config.port}`); + console.log("live session smoke passed"); + } else { + await runPostdeploySmoke(config); + } } diff --git a/src/harness/pi-harness.ts b/src/harness/pi-harness.ts index 4b272c0cd..0c8a96b34 100644 --- a/src/harness/pi-harness.ts +++ b/src/harness/pi-harness.ts @@ -9,7 +9,7 @@ import { type AgentSession, } from "@earendil-works/pi-coding-agent"; import { InMemoryCredentialStore, type Api, type Model } from "@earendil-works/pi-ai"; -import { CONFIG_DEFAULTS, type Config } from "../config.ts"; +import { baseModelProviders, CONFIG_DEFAULTS, type Config } from "../config.ts"; type LegacyThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; const LEGACY_THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]); @@ -41,6 +41,7 @@ import { DEFAULT_AGENT_MODEL_ID, auxiliaryModelFor, auxiliaryModelForProvider, + defaultModelForHarness, defaultInteractiveThinkingLevel, modelDisplayName, resolveModel, @@ -103,8 +104,11 @@ export interface PiHarnessOptions { } export function piHarnessConfigOptions(config: Config): PiHarnessOptions { + const defaultModelId = + config.modelId ?? + (config.modelProvider ? defaultModelForHarness("pi", undefined, baseModelProviders(config)) : undefined); return { - ...(config.modelId ? { defaultModelId: config.modelId } : {}), + ...(defaultModelId ? { defaultModelId } : {}), ...(config.detectModelId ? { detectModelId: config.detectModelId } : {}), ...(config.titleModelId ? { titleModelId: config.titleModelId } : {}), ...(config.judgeModelId ? { judgeModelId: config.judgeModelId } : {}), @@ -2028,21 +2032,13 @@ export function createPiHarness(opts?: PiHarnessOptions): Harness { async generateTitle(transcript: string): Promise { if (!transcript.trim()) return undefined; - try { - const model = getRequiredModel(titleModelId()); - const providerKeys = await resolveProviderKeys(); - if (!keyForModel(providerKeys, model)) return undefined; - const out = await oneShot( - "pi-title", - model, - providerKeys, - TITLE_GENERATION_PROMPT, - transcript.slice(0, 4000), - ); - return sanitizeTitle(out); - } catch { - return undefined; + const model = getRequiredModel(titleModelId()); + const providerKeys = await resolveProviderKeys(); + if (!keyForModel(providerKeys, model)) { + throw new Error(`Missing ${model.provider} credential for title model ${model.id}`); } + const out = await oneShot("pi-title", model, providerKeys, TITLE_GENERATION_PROMPT, transcript.slice(0, 4000)); + return sanitizeTitle(out); }, async summarizeApproval(command: string, reason: string, purpose?: string): Promise { diff --git a/src/types.ts b/src/types.ts index 1f76af2d8..ab5319b37 100644 --- a/src/types.ts +++ b/src/types.ts @@ -403,6 +403,7 @@ export interface TurnRequest { thinkingLevel?: string; fastMode?: boolean; readOnly?: boolean; + skipMemory?: boolean; surfaceTools?: boolean; addressed?: boolean; envelopeWrapped?: boolean; diff --git a/test/deployment-skill.test.ts b/test/deployment-skill.test.ts index 135d0890d..4ef25e261 100644 --- a/test/deployment-skill.test.ts +++ b/test/deployment-skill.test.ts @@ -17,7 +17,10 @@ test("package-consumer deployment skill covers both self-owned providers and the "slack render", "work-email OIDC provider", "check --live", + "private live session canary", "fresh UUID", + "generated sidebar title", + "Web chat", "idempotent", "test-channel links", "adminConnectorsUrl", diff --git a/test/finalize-on-reply-ready.test.ts b/test/finalize-on-reply-ready.test.ts index 86d0d07c7..43c1cffc1 100644 --- a/test/finalize-on-reply-ready.test.ts +++ b/test/finalize-on-reply-ready.test.ts @@ -189,6 +189,7 @@ test("background: an error in the detached tail still reclaims the box (no machi record() { throw new Error("error log unavailable"); }, + flush: () => Promise.resolve(), list: () => Promise.resolve([]), count: () => Promise.resolve(0), }; diff --git a/test/memory-capture-async.test.ts b/test/memory-capture-async.test.ts index 41539272b..c7c4e8dce 100644 --- a/test/memory-capture-async.test.ts +++ b/test/memory-capture-async.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createOrchestrator, type OrchestratorInput } from "../src/core/orchestrator.ts"; +import { replayableRequest } from "../src/core/orchestrator/turn-helpers.ts"; import { createIdentityService } from "../src/identity/identity-service.ts"; import { createMemoryConfigStore } from "../src/resolution/config-store.ts"; import { createAclStore } from "../src/acl/acl-store.ts"; @@ -11,6 +12,8 @@ import { createResolutionService } from "../src/resolution/resolution-service.ts import { createMemorySessionStore } from "../src/sessions/memory-session-store.ts"; import { createLocalWorkspaceStore } from "../src/workspace/workspace-store.ts"; import { createMemoryService } from "../src/memory/memory-service.ts"; +import type { MemoryService } from "../src/memory/memory-service.ts"; +import type { MemoryStrategy } from "../src/memory/strategy.ts"; import { createModelGateway } from "../src/model/model-gateway.ts"; import { createAuditLog } from "../src/audit/audit-log.ts"; import { createRateLimiter } from "../src/ratelimit/rate-limiter.ts"; @@ -86,7 +89,7 @@ function gatedHarness() { }; } -function buildOrchestrator(harness: Harness) { +function buildOrchestrator(harness: Harness, memory?: MemoryService, memoryStrategy?: MemoryStrategy) { const config = createMemoryConfigStore(ORG); const acl = createAclStore(); const auditLog = createAuditLog(); @@ -109,12 +112,40 @@ function buildOrchestrator(harness: Harness) { auditLog, rateLimiter: createRateLimiter({ maxPerWindow: 1000, windowMs: 60_000 }), harness, - memory: createMemoryService(workspace), + memory: memory ?? createMemoryService(workspace), + ...(memoryStrategy ? { memoryStrategy } : {}), deploy, acl, }); } +test("skipMemory turns neither recall nor capture", async () => { + let recalls = 0; + let captures = 0; + const memory: MemoryService = { + recall: async () => (recalls++, "remembered deployment state"), + capture: async () => 0, + query: async () => [], + read: async () => "", + replace: async () => {}, + }; + const orch = buildOrchestrator(createMockHarness(), memory, { + onTurnEnd: async () => { + captures++; + }, + }); + + const result = await orch.handleTurn({ ...dm("dm:U1:canary", "deployment canary"), skipMemory: true }); + + assert.equal(result.status, "ok"); + assert.equal(recalls, 0); + assert.equal(captures, 0); +}); + +test("approval replay preserves the memory opt-out", () => { + assert.equal(replayableRequest({ ...dm("dm:U1:approval", "deployment canary"), skipMemory: true }).skipMemory, true); +}); + test("capture does NOT block the turn: the reply returns while extraction is still in flight", async () => { const g = gatedHarness(); const orch = buildOrchestrator(g.harness); diff --git a/test/pi-harness-oneshot.test.ts b/test/pi-harness-oneshot.test.ts index 8ad04fa5a..ec6c8dc72 100644 --- a/test/pi-harness-oneshot.test.ts +++ b/test/pi-harness-oneshot.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; import { buildDetectionPrompt, + createPiHarness, oneShot, parseDetectVerdict, piHarnessConfigOptions, @@ -167,6 +168,23 @@ test("piHarnessConfigOptions leaves controlTools off unless a self-API (signing ); }); +test("piHarnessConfigOptions carries the deployment provider into Pi auxiliary model selection", () => { + const opts = piHarnessConfigOptions(testConfig({ modelProvider: "openai", openaiApiKey: "sk-openai-test" })); + assert.equal(opts.defaultModelId, "gpt-5.6-sol"); + assert.equal(auxiliaryModelFor(opts.defaultModelId!), "gpt-5.6-luna"); +}); + +test("Pi title generation surfaces a missing auxiliary-model credential", async () => { + const harness = createPiHarness({ + defaultModelId: "gpt-5.6-sol", + resolveProviderKeys: async () => ({}), + }); + await assert.rejects( + harness.models.generateTitle!("User:\nPrioritize the public qm issues"), + /missing openai credential for title model gpt-5\.6-luna/i, + ); +}); + test("piHarnessConfigOptions omits the optional fields when the config leaves them unset", () => { const opts = piHarnessConfigOptions(testConfig()); for (const key of ["defaultModelId", "detectModelId", "titleModelId", "apiKey"] as const) { diff --git a/test/postdeploy-smoke.test.ts b/test/postdeploy-smoke.test.ts index 01eddb69e..33453e6d9 100644 --- a/test/postdeploy-smoke.test.ts +++ b/test/postdeploy-smoke.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { checkDeployedHealth, + checkLiveSession, checkSlackCredentials, deployedHealthUrls, firstAdminPrincipal, @@ -82,3 +83,79 @@ test("deployed staging smoke verifies its own Slack bot and Socket Mode credenti /Slack auth\.test failed: invalid_auth/, ); }); + +test("live session smoke proves a model turn, persistence, title, error log, and cleanup", async () => { + const calls: Array<{ method: string; path: string; body: string }> = []; + const config = { + adminGrants: "josh@example.com:org_admin", + orgId: "acme", + portalIdentitySecret: "portal-secret", + signingSecret: "source-secret", + }; + await checkLiveSession(config, "http://core.internal:8080", async (input, init) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + calls.push({ method, path: `${url.pathname}${url.search}`, body: String(init?.body ?? "") }); + if (url.pathname === "/v1/turns") + return Response.json({ status: "ok", sessionId: "sess-1", reply: "QM deployment canary passed." }); + if (url.pathname === "/v1/admin/errors") return Response.json({ errors: [] }); + if (method === "POST") return Response.json({ session: { id: "sess-1", archived: true } }); + return Response.json({ + session: { id: "sess-1", title: "Deployment canary" }, + entries: [{ type: "user" }, { type: "assistant" }], + }); + }); + assert.deepEqual( + calls.map(({ method, path }) => [method, path]), + [ + ["POST", "/v1/turns"], + ["GET", "/v1/sessions/sess-1?viewer=josh%40example.com&tailTurns=1"], + ["GET", "/v1/admin/errors?scope=personal%3Ajosh%40example.com&sessionId=sess-1"], + ["POST", "/v1/sessions/sess-1"], + ], + ); + assert.equal(JSON.parse(calls[0]!.body).readOnly, true); + assert.equal(JSON.parse(calls[0]!.body).skipMemory, true); + assert.deepEqual(JSON.parse(calls[3]!.body), { principalId: "josh@example.com", archived: true }); + + let archivedFailedSession = false; + await assert.rejects( + checkLiveSession(config, "http://core.internal:8080", async (input, init) => { + const path = new URL(String(input)).pathname; + if (path === "/v1/turns") + return Response.json({ status: "ok", sessionId: "sess-2", reply: "QM deployment canary passed." }); + if (path === "/v1/sessions/sess-2" && init?.method === "POST") archivedFailedSession = true; + return Response.json({ session: { id: "sess-2" }, entries: [{ type: "user" }, { type: "assistant" }] }); + }), + /generated title/, + ); + assert.equal(archivedFailedSession, true); + + await assert.rejects( + checkLiveSession(config, "http://core.internal:8080", async (input) => { + const path = new URL(String(input)).pathname; + if (path === "/v1/turns") return Response.json({ status: "ok", sessionId: "sess-3", reply: "Looks good" }); + return Response.json({ session: { id: "sess-3", archived: true } }); + }), + /unexpected model reply/, + ); + + let failedThreadRef = ""; + let archivedFailedRequest = false; + await assert.rejects( + checkLiveSession(config, "http://core.internal:8080", async (input, init) => { + const url = new URL(String(input)); + if (url.pathname === "/v1/turns") { + failedThreadRef = JSON.parse(String(init?.body)).conversation.threadRef as string; + return new Response("model failed", { status: 500 }); + } + if (url.pathname === "/v1/admin/sessions") { + return Response.json({ sessions: [{ id: "sess-500", threadRef: failedThreadRef }] }); + } + if (url.pathname === "/v1/sessions/sess-500" && init?.method === "POST") archivedFailedRequest = true; + return Response.json({ session: { id: "sess-500", archived: true } }); + }), + /returned 500/, + ); + assert.equal(archivedFailedRequest, true); +}); diff --git a/test/postgres-error-log.test.ts b/test/postgres-error-log.test.ts index 3aa88a741..eb6d10246 100644 --- a/test/postgres-error-log.test.ts +++ b/test/postgres-error-log.test.ts @@ -2,7 +2,6 @@ import { test, before } from "node:test"; import assert from "node:assert/strict"; import { createPostgresErrorLog } from "../src/admin/postgres-error-log.ts"; import { scopeId } from "../src/types.ts"; -import { settle } from "./support/settle.ts"; const URL = process.env.DATABASE_URL; const skip = URL ? false : "set DATABASE_URL (a Postgres) to run the Postgres error-log tests"; @@ -28,7 +27,6 @@ test("pg error log: persists events, filters by scope, newest-first, shape-only" sessionId: "sess-1", }); log.record({ category: "turn", code: "error", message: "boom (shape-only)", scopeLabel: s2 }); - await settle(async () => (await log.list({ limit: 100 })).length === 2); const all = await log.list({ limit: 100 }); assert.equal(all.length, 2, "both events persisted"); @@ -57,3 +55,18 @@ test("pg error log: survives a fresh log over the same table (durability)", { sk const rows = await reopened.list({ limit: 100 }); assert.ok(rows.length >= 2, "events written by a prior log instance are still readable"); }); + +test("pg error log: flush makes writes visible to another process", { skip }, async () => { + const writer = createPostgresErrorLog(URL!); + const reader = createPostgresErrorLog(URL!); + writer.record({ + category: "session_title", + code: "generation_failed", + message: "cross-process barrier", + scopeLabel: scopeId("personal", "U1"), + sessionId: "sess-cross-process", + }); + + await writer.flush(); + assert.equal((await reader.list({ sessionId: "sess-cross-process" })).length, 1); +}); diff --git a/test/turns-union-guard.test.ts b/test/turns-union-guard.test.ts index 3156911aa..4c858c952 100644 --- a/test/turns-union-guard.test.ts +++ b/test/turns-union-guard.test.ts @@ -36,6 +36,7 @@ test("POST /v1/turns strips ownerKeychainUnion from the external body but keeps triggered: true, ownerKeychainUnion: true, readOnly: true, + skipMemory: true, async: true, }); const r = await fetch(`${base}/v1/turns`, { @@ -52,6 +53,7 @@ test("POST /v1/turns strips ownerKeychainUnion from the external body but keeps "external union injection is removed while trigger provenance survives", ); assert.equal(run?.request.readOnly, true, "non-internal fields are still forwarded"); + assert.equal(run?.request.skipMemory, true, "the source-authenticated memory opt-out is forwarded"); }); test("POST /v1/turns strips nested owner-keychain union from typed automation origin", async () => {