feat(goose): add local v1.49 canary routing, smoke gates, and migration docs
Wire Portal and TKMind proxy to loopback Goose v1.49 via canary env blocks, with verification scripts, Phase 2/3 evidence baselines, and rollback runbooks so local upgrade stays isolated from stable 1.41 and production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase 2 smoke: POST /sessions/{id}/reply + GET /sessions/{id}/events (SSE).
|
||||
* Validates the event bus pipeline; Finish or Error both count as SSE_OK.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { Agent, fetch } from 'undici';
|
||||
|
||||
const port = process.env.GOOSE_V149_PORT || '18049';
|
||||
const host = process.env.GOOSE_V149_HOST || '127.0.0.1';
|
||||
const secret = process.env.GOOSE_SERVER__SECRET_KEY || 'local-v149-dev-secret';
|
||||
const workingDir = process.env.GOOSE_V149_WORKING_DIR || process.cwd();
|
||||
const timeoutMs = Number(process.env.GOOSE_V149_REPLY_TIMEOUT_MS || 30_000);
|
||||
|
||||
const blockedHosts = ['58.38.22.103', '120.26.184.105'];
|
||||
if (blockedHosts.includes(host)) {
|
||||
console.error(`GOOSE_V149_REPLY_SMOKE_FAIL: refusing production host ${host}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const base = `https://${host}:${port}`;
|
||||
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
async function apiFetch(pathname, init = {}) {
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': secret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return fetch(`${base}${pathname}`, {
|
||||
...init,
|
||||
headers,
|
||||
dispatcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function apiJson(pathname, body) {
|
||||
const response = await apiFetch(pathname, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
|
||||
}
|
||||
return text.trim() ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
|
||||
async function waitForTerminalEvent(sessionId, requestId) {
|
||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw new Error(`events ${eventsResponse.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [{ type: 'text', text: 'ping' }],
|
||||
metadata: { userVisible: true, agentVisible: true, displayText: 'ping' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(`reply ${replyResponse.status}: ${text.slice(0, 400)}`);
|
||||
}
|
||||
replyResponse.body?.cancel?.();
|
||||
|
||||
const reader = Readable.fromWeb(eventsResponse.body);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
for await (const chunk of reader) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`timeout waiting for Finish/Error (${timeoutMs}ms)`);
|
||||
}
|
||||
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
|
||||
for (const frame of frames) {
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routingId = event.chat_request_id ?? event.request_id;
|
||||
if (routingId && routingId !== requestId) continue;
|
||||
|
||||
if (event.type === 'Finish') {
|
||||
console.log('GOOSE_V149_REPLY_EVENT: type=Finish');
|
||||
return 'finish';
|
||||
}
|
||||
if (event.type === 'Error') {
|
||||
console.log(`GOOSE_V149_REPLY_EVENT: type=Error error=${String(event.error ?? '').slice(0, 200)}`);
|
||||
return 'error';
|
||||
}
|
||||
if (event.type === 'Message') {
|
||||
console.log('GOOSE_V149_REPLY_EVENT: type=Message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`SSE stream closed before Finish/Error (${timeoutMs}ms)`);
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await apiJson('/agent/start', { working_dir: workingDir });
|
||||
if (!session?.id) throw new Error('missing session id from /agent/start');
|
||||
|
||||
const provider = process.env.GOOSE_V149_TEST_PROVIDER;
|
||||
const model = process.env.GOOSE_V149_TEST_MODEL;
|
||||
if (provider && model) {
|
||||
await apiJson('/agent/update_provider', {
|
||||
session_id: session.id,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
console.log(`GOOSE_V149_REPLY_PROVIDER_OK: ${provider}/${model}`);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const outcome = await waitForTerminalEvent(session.id, requestId);
|
||||
console.log(`GOOSE_V149_REPLY_SMOKE_OK: session=${session.id} request=${requestId} outcome=${outcome}`);
|
||||
} catch (error) {
|
||||
console.error(`GOOSE_V149_REPLY_SMOKE_FAIL: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user