@danceiny/gotry 0.2.0-rc.29 → 0.2.0-rc.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -83
- package/bin/gotry-extension-status.d.ts +12 -0
- package/bin/gotry-extension-status.js +113 -0
- package/bin/gotry-flyai-verification.d.ts +9 -0
- package/bin/gotry-flyai-verification.js +63 -0
- package/bin/gotry-inner.js +9 -2
- package/client/client.js +183 -0
- package/cordis.gotry-patch.yml +2 -0
- package/dist/capabilities/artifacts.js +41 -4
- package/dist/capabilities/doctor.js +7 -15
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +2 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +135 -2
- package/dist/scripts/artifact-client-contract-tests.js +4 -3
- package/dist/scripts/artifact-delivery-tests.js +31 -0
- package/dist/scripts/bootstrap-tests.js +19 -4
- package/dist/scripts/doctor-tests.js +36 -7
- package/dist/scripts/dsh-artifact-web-e2e.js +204 -43
- package/dist/scripts/flyai-setup-tests.js +75 -2
- package/dist/scripts/gotry-web-api-tests.js +114 -0
- package/dist/scripts/gotry-web-client-tests.js +28 -0
- package/dist/scripts/issue-271-liveness-tests.js +9 -1
- package/dist/scripts/turn-handoff-collect-tests.js +7 -1
- package/dist/scripts/turn-handoff-collect.js +68 -20
- package/dist/scripts/turn-handoff-job-tests.js +222 -0
- package/dist/scripts/turn-handoff-web-e2e.js +240 -0
- package/dist/src/artifact-delivery.js +26 -0
- package/dist/src/gotry-web-api.js +157 -0
- package/dist/src/index.js +30 -8
- package/dist/src/turn-deadline.js +113 -16
- package/dist/src/turn-handoff-job.js +124 -0
- package/extension/manifest.json +2 -2
- package/package.json +8 -2
- package/ts/capabilities/artifacts.ts +40 -5
- package/ts/scripts/turn-handoff-collect.ts +63 -21
- package/ts/src/artifact-delivery.ts +25 -0
- package/ts/src/gotry-web-api.ts +140 -0
- package/ts/src/index.ts +30 -8
- package/ts/src/turn-deadline.ts +111 -18
- package/ts/src/turn-handoff-job.ts +120 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
import { LocalJobRegistry } from '@deepseek-ai/dsh-jobs-local';
|
|
7
|
+
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local';
|
|
8
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
9
|
+
import { JobId } from '@deepseek-ai/dsh-jobs';
|
|
10
|
+
import { writeTurnHandoffTicket, writeTicketJson, listTurnHandoffTickets } from '../src/turn-deadline.js';
|
|
11
|
+
import { startTurnHandoffJob } from '../src/turn-handoff-job.js';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { listArtifacts, readArtifact } from '../capabilities/artifacts.js';
|
|
15
|
+
import { apply } from '../src/index.js';
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), 'gotry-native-handoff-'));
|
|
17
|
+
const ctx = new Context();
|
|
18
|
+
const owner = { id: SessionId('owner'), ctx };
|
|
19
|
+
ctx.provide('agents', { get: (id) => id === 'owner' ? owner : undefined });
|
|
20
|
+
const jobs = new LocalJobRegistry(ctx, { maxConcurrentJobsPerOwner: 10 });
|
|
21
|
+
const subprocess = new LocalSubprocessRuntime(ctx);
|
|
22
|
+
const detach = jobs.attachController('test');
|
|
23
|
+
const planner = join(root, 'planner.mjs');
|
|
24
|
+
const descendants = [];
|
|
25
|
+
writeFileSync(planner, `console.error('DIAGNOSTIC, NOT THE DELIVERABLE');console.log('# Finished plan\\nOriginal request: '+process.argv[2])`);
|
|
26
|
+
process.env.GOTRY_HANDOFF_PLANNER_BIN = planner;
|
|
27
|
+
try {
|
|
28
|
+
const ticket = await writeTurnHandoffTicket(root, '东北旅行', { workspaceCwd: root, ownerSessionId: owner.id });
|
|
29
|
+
const running = await startTurnHandoffJob(ctx, ticket, owner, root);
|
|
30
|
+
assert.equal(running.status, 'running');
|
|
31
|
+
assert.ok(running.jobId);
|
|
32
|
+
assert.equal(jobs.list(owner.id)[0].label.includes(ticket.id), true, 'native UI jobs carry the durable ticket identity');
|
|
33
|
+
assert.deepEqual(jobs.list(SessionId('foreign')), [], 'another session cannot see the task');
|
|
34
|
+
const jobId = JobId(running.jobId);
|
|
35
|
+
assert.equal((await jobs.wait(jobId, 10_000, owner.id)).status, 'completed');
|
|
36
|
+
const views = await listTurnHandoffTickets(root);
|
|
37
|
+
assert.equal(views[0].status, 'settled');
|
|
38
|
+
assert.ok(views[0].deliverablePath);
|
|
39
|
+
assert.match(jobs.read(jobId, owner.id).result ?? '', /present/, 'completion tells the owning session to present the output');
|
|
40
|
+
const content = readFileSync(views[0].deliverablePath, 'utf8');
|
|
41
|
+
assert.ok(content.includes('# Finished plan'));
|
|
42
|
+
assert.ok(!content.includes('DIAGNOSTIC'), 'stderr cannot be accepted as the plan');
|
|
43
|
+
const artifacts = await listArtifacts({ stateRoot: root, cwd: root });
|
|
44
|
+
assert.ok(artifacts.artifacts.some(item => item.id === ticket.id), 'handoff deliverable must be discoverable');
|
|
45
|
+
assert.equal((await readArtifact({ stateRoot: root, cwd: root, path: ticket.id })).ok, true);
|
|
46
|
+
const legacyRoot = join(root, 'legacy-root');
|
|
47
|
+
mkdirSync(join(legacyRoot, 'gotry-state', 'async'), { recursive: true });
|
|
48
|
+
writeFileSync(join(legacyRoot, 'gotry-state', 'async', 'legacy.deliverable.md'), 'Legacy plan');
|
|
49
|
+
const mixed = await listArtifacts({ stateRoot: legacyRoot, cwd: root });
|
|
50
|
+
assert.ok(mixed.artifacts.some(item => item.id === 'legacy'), 'existing async outputs stay reachable');
|
|
51
|
+
assert.ok(mixed.artifacts.some(item => item.id === ticket.id), 'workspace handoffs coexist with configured async state');
|
|
52
|
+
assert.equal((await readArtifact({ stateRoot: legacyRoot, cwd: root, path: ticket.id })).ok, true);
|
|
53
|
+
const settled = JSON.parse(readFileSync(join(root, 'gotry-state', 'turn-handoffs', `${ticket.id}.json`), 'utf8'));
|
|
54
|
+
const before = jobs.list(owner.id).length;
|
|
55
|
+
assert.equal((await startTurnHandoffJob(ctx, settled, owner, root)).status, 'settled');
|
|
56
|
+
assert.equal(jobs.list(owner.id).length, before, 'terminal replay must not start more work');
|
|
57
|
+
writeFileSync(planner, `setTimeout(()=>console.log('must not complete'),60000)`);
|
|
58
|
+
const pending = await writeTurnHandoffTicket(root, 'cancel', { workspaceCwd: root });
|
|
59
|
+
const cancellable = await startTurnHandoffJob(ctx, pending, owner, root);
|
|
60
|
+
jobs.kill(JobId(cancellable.jobId), owner.id);
|
|
61
|
+
assert.equal((await jobs.wait(JobId(cancellable.jobId), 10_000, owner.id)).status, 'killed');
|
|
62
|
+
const cancelled = (await listTurnHandoffTickets(root)).find(item => item.id === pending.id);
|
|
63
|
+
assert.equal(cancelled.status, 'failed', 'cancellation must settle the durable ticket');
|
|
64
|
+
assert.match(cancelled.error, /cancelled/);
|
|
65
|
+
// The native API separates direct exit from managed-range quiescence.
|
|
66
|
+
// A cancelled collector can exit while the host still owns a live child.
|
|
67
|
+
for (const mode of ['cancel', 'direct-error', 'cleanup-error']) {
|
|
68
|
+
const actualSpawn = subprocess.spawn;
|
|
69
|
+
let settleDirect;
|
|
70
|
+
let releaseRange;
|
|
71
|
+
let rangeWaited = false;
|
|
72
|
+
let terminationRequested = false;
|
|
73
|
+
const directExited = new Promise((resolve, reject) => {
|
|
74
|
+
settleDirect = () => mode !== 'direct-error' ? resolve({ exitCode: 0, signal: null }) : reject(new Error('synthetic collector direct failure'));
|
|
75
|
+
});
|
|
76
|
+
const rangeExited = new Promise(resolve => { releaseRange = resolve; });
|
|
77
|
+
subprocess.spawn = () => ({
|
|
78
|
+
stdin: undefined, stdout: undefined, stderr: undefined, control: undefined,
|
|
79
|
+
done: directExited, collected: {}, terminate() { terminationRequested = true; }, terminateForHostExit() { },
|
|
80
|
+
async waitForExit() {
|
|
81
|
+
rangeWaited = true;
|
|
82
|
+
if (mode === 'cleanup-error')
|
|
83
|
+
throw new Error('synthetic range observation failure');
|
|
84
|
+
await rangeExited;
|
|
85
|
+
return true;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
let heldJobId;
|
|
89
|
+
let heldTicketId;
|
|
90
|
+
try {
|
|
91
|
+
const heldTicket = await writeTurnHandoffTicket(root, `${mode}: direct exit before range quiescence`, { workspaceCwd: root });
|
|
92
|
+
heldTicketId = heldTicket.id;
|
|
93
|
+
const held = await startTurnHandoffJob(ctx, heldTicket, owner, root);
|
|
94
|
+
heldJobId = JobId(held.jobId);
|
|
95
|
+
if (mode === 'cancel')
|
|
96
|
+
jobs.kill(heldJobId, owner.id);
|
|
97
|
+
settleDirect();
|
|
98
|
+
if (mode === 'cleanup-error') {
|
|
99
|
+
assert.equal((await jobs.wait(heldJobId, 10_000, owner.id)).status, 'failed');
|
|
100
|
+
assert.equal(terminationRequested, true, 'cleanup observation failure must still request termination');
|
|
101
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === heldTicket.id).error, /managed-range cleanup could not be confirmed.*synthetic range observation failure/, 'cleanup failure is explicit, never a stopped-process claim');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const activeStatus = mode === 'cancel' ? 'stopping' : 'running';
|
|
105
|
+
const waitBy = Date.now() + 2000;
|
|
106
|
+
while (!rangeWaited && jobs.list(owner.id).find(job => job.id === heldJobId)?.status === activeStatus && Date.now() < waitBy) {
|
|
107
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
108
|
+
}
|
|
109
|
+
assert.equal(rangeWaited, true, `${mode}: direct exit must join the host-owned process range`);
|
|
110
|
+
assert.equal(jobs.list(owner.id).find(job => job.id === heldJobId)?.status, activeStatus, `${mode}: native outcome cannot settle while the managed range is live`);
|
|
111
|
+
assert.equal((await listTurnHandoffTickets(root)).find(item => item.id === heldTicket.id).status, 'running', `${mode}: durable settlement waits for managed-range quiescence too`);
|
|
112
|
+
assert.equal(terminationRequested, true, `${mode}: cleanup must be initiated`);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
settleDirect();
|
|
116
|
+
releaseRange();
|
|
117
|
+
if (heldJobId)
|
|
118
|
+
await jobs.wait(heldJobId, 10_000, owner.id);
|
|
119
|
+
subprocess.spawn = actualSpawn;
|
|
120
|
+
}
|
|
121
|
+
if (mode === 'direct-error') {
|
|
122
|
+
assert.equal(jobs.list(owner.id).find(job => job.id === heldJobId)?.status, 'failed');
|
|
123
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === heldTicketId).error, /synthetic collector direct failure/, 'keep the direct diagnostic after cleanup');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
writeFileSync(planner, `setTimeout(()=>console.log('late'),60000)`);
|
|
127
|
+
process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS = '50';
|
|
128
|
+
const timeoutTicket = await writeTurnHandoffTicket(root, 'timeout', { workspaceCwd: root });
|
|
129
|
+
const timeoutJob = await startTurnHandoffJob(ctx, timeoutTicket, owner, root);
|
|
130
|
+
assert.equal((await jobs.wait(JobId(timeoutJob.jobId), 10_000, owner.id)).status, 'failed');
|
|
131
|
+
assert.match((await listTurnHandoffTickets(root)).find(item => item.id === timeoutTicket.id).error, /timed out/);
|
|
132
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
133
|
+
// Match gotry-inner's detached DSH child, inherited pipes and real TERM
|
|
134
|
+
// forwarding bound. Its child ignores TERM, requiring wrapper-owned KILL.
|
|
135
|
+
if (process.platform !== 'win32') {
|
|
136
|
+
const pidFile = join(root, 'descendant.pid');
|
|
137
|
+
const readyFile = join(root, 'descendant.ready');
|
|
138
|
+
const lifecycle = pathToFileURL(join(import.meta.dirname, '..', '..', 'bin', 'gotry-process-liveness.js')).href;
|
|
139
|
+
const childCode = `const fs=require('node:fs');process.on('SIGTERM',()=>{});fs.writeFileSync(${JSON.stringify(readyFile)},'ready');setInterval(()=>{},1000)`;
|
|
140
|
+
writeFileSync(planner, `
|
|
141
|
+
import {writeFileSync} from 'node:fs';
|
|
142
|
+
import {spawnOwnedChild,terminateOwnedChild} from ${JSON.stringify(lifecycle)};
|
|
143
|
+
const {child,groupPid}=spawnOwnedChild(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:['ignore','inherit','inherit']});
|
|
144
|
+
writeFileSync(${JSON.stringify(pidFile)},String(child.pid));
|
|
145
|
+
let stopping=false;
|
|
146
|
+
process.on('SIGTERM',()=>{if(stopping)return;stopping=true;void terminateOwnedChild({child,groupPid}).then(()=>process.exit(143))});
|
|
147
|
+
setInterval(()=>{},1000);
|
|
148
|
+
`);
|
|
149
|
+
for (const mode of ['timeout', 'cancel']) {
|
|
150
|
+
rmSync(readyFile, { force: true });
|
|
151
|
+
rmSync(pidFile, { force: true });
|
|
152
|
+
if (mode === 'timeout')
|
|
153
|
+
process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS = '1000';
|
|
154
|
+
else
|
|
155
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
156
|
+
const nestedTicket = await writeTurnHandoffTicket(root, `nested ${mode}`, { workspaceCwd: root });
|
|
157
|
+
const nestedJob = await startTurnHandoffJob(ctx, nestedTicket, owner, root);
|
|
158
|
+
const readyBy = Date.now() + 5000;
|
|
159
|
+
while (!existsSync(readyFile) && Date.now() < readyBy)
|
|
160
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
161
|
+
assert.ok(existsSync(readyFile), 'detached stubborn child actually started');
|
|
162
|
+
const pid = Number(readFileSync(pidFile, 'utf8'));
|
|
163
|
+
descendants.push(pid);
|
|
164
|
+
if (mode === 'cancel')
|
|
165
|
+
jobs.kill(JobId(nestedJob.jobId), owner.id);
|
|
166
|
+
const terminal = await jobs.wait(JobId(nestedJob.jobId), 15_000, owner.id);
|
|
167
|
+
assert.equal(terminal.status, mode === 'cancel' ? 'killed' : 'failed');
|
|
168
|
+
const processRows = execFileSync('ps', ['-eo', 'pid=,ppid=,pgid=,stat='], { encoding: 'utf8', timeout: 2000 })
|
|
169
|
+
.split('\n').filter(line => Number(line.trim().split(/\s+/)[2]) === pid);
|
|
170
|
+
assert.throws(() => process.kill(-pid, 0), { code: 'ESRCH' }, `${mode} cannot report terminal while the detached planner child is alive; group members=${JSON.stringify(processRows)}`);
|
|
171
|
+
const view = (await listTurnHandoffTickets(root)).find(item => item.id === nestedTicket.id);
|
|
172
|
+
assert.equal(view.status, 'failed');
|
|
173
|
+
assert.match(view.error, mode === 'cancel' ? /cancelled/ : /timed out/);
|
|
174
|
+
}
|
|
175
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
176
|
+
}
|
|
177
|
+
// A fresh host can reuse a native counter id. Its new job must never make
|
|
178
|
+
// an older persisted running ticket look active or offer the wrong controls.
|
|
179
|
+
writeFileSync(planner, `setTimeout(()=>console.log('late'),60000)`);
|
|
180
|
+
const freshTicket = await writeTurnHandoffTicket(root, 'new host trip', { workspaceCwd: root });
|
|
181
|
+
const fresh = await startTurnHandoffJob(ctx, freshTicket, owner, root);
|
|
182
|
+
const stale = await writeTurnHandoffTicket(root, 'old interrupted trip', { workspaceCwd: root });
|
|
183
|
+
await writeTicketJson(root, { ...stale, status: 'running', jobId: fresh.jobId });
|
|
184
|
+
let listTool;
|
|
185
|
+
const queryCtx = {
|
|
186
|
+
tools: { register(tool) { if (tool.name === 'gotry_turn_handoff_list')
|
|
187
|
+
listTool = tool; } },
|
|
188
|
+
systemPrompt: { variable() { } }, on() { return () => { }; },
|
|
189
|
+
get: (name) => name === 'jobs' ? jobs : undefined,
|
|
190
|
+
};
|
|
191
|
+
apply(queryCtx, { stateRoot: root, timeoutMs: 1000, hbcliBin: '', sessionAccess: 'off' });
|
|
192
|
+
const oldQuery = await listTool.execute({ ticketId: stale.id }, { agent: owner });
|
|
193
|
+
assert.match(oldQuery.summary, /已中断/, 'reused native id does not identify another saved ticket');
|
|
194
|
+
const newQuery = await listTool.execute({ ticketId: fresh.id }, { agent: owner });
|
|
195
|
+
assert.match(newQuery.summary, /后台任务/, 'exact current ticket still has live controls');
|
|
196
|
+
jobs.kill(JobId(fresh.jobId), owner.id);
|
|
197
|
+
await jobs.wait(JobId(fresh.jobId), 15_000, owner.id);
|
|
198
|
+
const unhosted = new Context();
|
|
199
|
+
const waiting = await writeTurnHandoffTicket(root, 'no execution services');
|
|
200
|
+
assert.equal((await startTurnHandoffJob(unhosted, waiting, owner, root)).status, 'open');
|
|
201
|
+
assert.equal((await listTurnHandoffTickets(root)).find(item => item.id === waiting.id).jobId, undefined);
|
|
202
|
+
await unhosted.fiber.dispose();
|
|
203
|
+
// A missing controller creates no execution resource and no running claim.
|
|
204
|
+
detach();
|
|
205
|
+
const queued = await writeTurnHandoffTicket(root, 'queued');
|
|
206
|
+
const rejected = await startTurnHandoffJob(ctx, queued, owner, root);
|
|
207
|
+
assert.equal(rejected.status, 'failed');
|
|
208
|
+
assert.ok(rejected.error);
|
|
209
|
+
console.log('native handoff job tests: OK (real registry/process, ownership, managed-range quiescence, settlement, artifacts, idempotence, cancellation, controller rejection)');
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_BIN;
|
|
213
|
+
delete process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS;
|
|
214
|
+
for (const pid of descendants) {
|
|
215
|
+
try {
|
|
216
|
+
process.kill(-pid, 'SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
catch { }
|
|
219
|
+
}
|
|
220
|
+
await ctx.fiber.dispose();
|
|
221
|
+
rmSync(root, { recursive: true, force: true });
|
|
222
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/** Isolated DSH Web handoff journey. Synthetic model/planner; real host, jobs,
|
|
2
|
+
* processes, composer, completion notice, presentation and artifact tools.
|
|
3
|
+
* Never a live travel-research or published-package acceptance receipt. */
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { basename, join } from 'node:path';
|
|
10
|
+
import puppeteer from 'puppeteer-core';
|
|
11
|
+
const ROOT = join(import.meta.dirname, '..', '..');
|
|
12
|
+
const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim();
|
|
13
|
+
const sourceDirty = execFileSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' }).trim() !== '';
|
|
14
|
+
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
15
|
+
const evidence = process.env.GOTRY_HANDOFF_WEB_E2E_OUT ?? mkdtempSync(join(tmpdir(), 'gotry-handoff-web-evidence-'));
|
|
16
|
+
const isolated = mkdtempSync(join(tmpdir(), 'gotry-handoff-web-'));
|
|
17
|
+
const product = join(isolated, 'product');
|
|
18
|
+
const hostCwd = join(isolated, 'host');
|
|
19
|
+
const workspace = join(isolated, 'workspace');
|
|
20
|
+
const stateRoot = join(isolated, 'handoff-state');
|
|
21
|
+
const home = join(isolated, 'home');
|
|
22
|
+
const dshHome = join(isolated, 'dsh');
|
|
23
|
+
for (const dir of [evidence, product, hostCwd, workspace, stateRoot, home, dshHome])
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
const planner = join(isolated, 'planner.mjs');
|
|
26
|
+
writeFileSync(planner, `console.error('DIAGNOSTIC_NOT_A_PLAN');setTimeout(()=>console.log('# Completed synthetic plan\\nWorkspace: '+process.cwd()+'\\n'+process.argv[2]),15000)`);
|
|
27
|
+
const bodies = [];
|
|
28
|
+
const served = [];
|
|
29
|
+
let phase = 0;
|
|
30
|
+
let serverLog = '';
|
|
31
|
+
let notified = false;
|
|
32
|
+
let relayError;
|
|
33
|
+
let browser;
|
|
34
|
+
let page;
|
|
35
|
+
let child;
|
|
36
|
+
const pageErrors = [];
|
|
37
|
+
const sse = (data) => `data: ${JSON.stringify(data)}\n\n`;
|
|
38
|
+
function response(name, input = {}, text = '') {
|
|
39
|
+
const block = name ? { type: 'tool_use', id: `handoff-${phase}`, name, input } : { type: 'text', text };
|
|
40
|
+
return sse({ type: 'message_start', message: { usage: { input_tokens: 10 } } })
|
|
41
|
+
+ sse({ type: 'content_block_start', index: 0, content_block: block })
|
|
42
|
+
+ sse({ type: 'content_block_stop', index: 0 })
|
|
43
|
+
+ sse({ type: 'message_delta', delta: { stop_reason: name ? 'tool_use' : 'end_turn' }, usage: { output_tokens: 10 } })
|
|
44
|
+
+ sse({ type: 'message_stop' }) + 'data: [DONE]\n\n';
|
|
45
|
+
}
|
|
46
|
+
const ticketDir = join(stateRoot, 'gotry-state', 'turn-handoffs');
|
|
47
|
+
function ticket() {
|
|
48
|
+
const names = existsSync(ticketDir) ? readdirSync(ticketDir).filter(name => name.endsWith('.json')) : [];
|
|
49
|
+
assert.equal(names.length, 1, 'one durable ticket');
|
|
50
|
+
return JSON.parse(readFileSync(join(ticketDir, names[0]), 'utf8'));
|
|
51
|
+
}
|
|
52
|
+
const relay = createServer((req, res) => {
|
|
53
|
+
if (req.method !== 'POST' || !req.url?.endsWith('/messages')) {
|
|
54
|
+
res.writeHead(404).end();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const chunks = [];
|
|
58
|
+
req.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
try {
|
|
61
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
62
|
+
bodies.push(body);
|
|
63
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
|
|
64
|
+
const names = (body.tools ?? []).map((tool) => tool.name ?? tool.function?.name);
|
|
65
|
+
const messages = JSON.stringify(body.messages ?? []);
|
|
66
|
+
const isPlanner = names.includes('write') || names.includes('present') || names.includes('job_output');
|
|
67
|
+
if (!isPlanner) {
|
|
68
|
+
res.end(response(undefined, {}, 'Synthetic title'));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let name;
|
|
72
|
+
let input = {};
|
|
73
|
+
let final = '';
|
|
74
|
+
let delay = 0;
|
|
75
|
+
if (phase === 0) {
|
|
76
|
+
name = 'write';
|
|
77
|
+
input = { file_path: 'exploration.md', content: '# Synthetic exploration\nWork hours 9-18 CST; original request retained.' };
|
|
78
|
+
// Cross the foreground deadline deliberately, while giving the later
|
|
79
|
+
// completion/query turn enough time for several native tool round trips.
|
|
80
|
+
delay = 5500;
|
|
81
|
+
}
|
|
82
|
+
else if (phase === 1) {
|
|
83
|
+
name = 'present';
|
|
84
|
+
input = { files: [{ path: join(workspace, 'exploration.md'), description: '探索稿' }] };
|
|
85
|
+
}
|
|
86
|
+
else if (phase === 2) {
|
|
87
|
+
final = '探索稿已保存,后台任务已启动。';
|
|
88
|
+
}
|
|
89
|
+
else if (phase === 3) {
|
|
90
|
+
assert.match(messages, /finished.*completed/, 'native completion notice wakes the owning session');
|
|
91
|
+
notified = true;
|
|
92
|
+
name = 'job_output';
|
|
93
|
+
input = { job_id: ticket().jobId, wait: false };
|
|
94
|
+
}
|
|
95
|
+
else if (phase === 4) {
|
|
96
|
+
assert.ok(messages.includes('Planning completed:'), 'native job result reaches the model');
|
|
97
|
+
name = 'present';
|
|
98
|
+
input = { files: [{ path: join(ticketDir, ticket().deliverableFile), description: '最终交付物' }] };
|
|
99
|
+
}
|
|
100
|
+
else if (phase === 5) {
|
|
101
|
+
name = 'gotry_artifacts_list';
|
|
102
|
+
input = { limit: 20 };
|
|
103
|
+
}
|
|
104
|
+
else if (phase === 6) {
|
|
105
|
+
assert.ok(messages.includes(ticket().id), 'artifact list includes the same ticket');
|
|
106
|
+
name = 'gotry_artifacts_read';
|
|
107
|
+
input = { path: ticket().id };
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
final = '最终规划已完成并展示,可按工单重新读取。';
|
|
111
|
+
}
|
|
112
|
+
if (name) {
|
|
113
|
+
assert.ok(names.includes(name), `${name} remains available in phase ${phase}: ${names}`);
|
|
114
|
+
served.push(name);
|
|
115
|
+
}
|
|
116
|
+
phase++;
|
|
117
|
+
setTimeout(() => res.end(response(name, input, final)), delay);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
relayError = String(error);
|
|
121
|
+
res.end(response(undefined, {}, 'RELAY_TEST_FAILURE'));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
assert.ok(existsSync(CHROME), 'Chrome is required for the browser receipt');
|
|
127
|
+
assert.ok(existsSync(join(ROOT, 'dist/src/turn-handoff-job.js')), 'build dist before the browser journey');
|
|
128
|
+
// The real source launcher normally uses ts/dsh-runtime as host cwd. Run the
|
|
129
|
+
// exact built package in a private layout instead, so even legacy state reads
|
|
130
|
+
// and process-guard logs cannot touch the founder's runtime directory.
|
|
131
|
+
const packed = JSON.parse(execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', isolated], { cwd: ROOT, encoding: 'utf8' }))[0];
|
|
132
|
+
execFileSync('tar', ['xzf', join(isolated, packed.filename), '--strip-components=1', '-C', product]);
|
|
133
|
+
symlinkSync(join(ROOT, 'node_modules'), join(product, 'node_modules'), 'dir');
|
|
134
|
+
await new Promise(resolve => relay.listen(0, '127.0.0.1', resolve));
|
|
135
|
+
const port = relay.address().port;
|
|
136
|
+
child = spawn(process.execPath, [join(product, 'bin/gotry-inner.js'), 'web', '--no-open', '--port', '0'], {
|
|
137
|
+
cwd: hostCwd,
|
|
138
|
+
env: { ...process.env, HOME: home, DSH_HOME: dshHome, GOTRY_SETUP_SKIP: '1', GOTRY_ONBOARDING_SKIP: '1',
|
|
139
|
+
GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0',
|
|
140
|
+
LLM_API_KEY: 'synthetic-only', LLM_BASE_URL: `http://127.0.0.1:${port}/v1`, LLM_MODEL: 'synthetic-handoff-web',
|
|
141
|
+
GOTRY_LLM_MODEL: 'synthetic-handoff-web', DEEPSEEK_API_KEY: 'synthetic-only', DEEPSEEK_BASE_URL: `http://127.0.0.1:${port}/v1`,
|
|
142
|
+
GOTRY_TURN_HANDOFF_ROOT: stateRoot, GOTRY_HANDOFF_PLANNER_BIN: planner,
|
|
143
|
+
GOTRY_TURN_DEADLINE_SOFT_MS: '2000', GOTRY_TURN_DEADLINE_HARD_MS: '5000' },
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
});
|
|
146
|
+
const url = await new Promise((resolve, reject) => {
|
|
147
|
+
const timer = setTimeout(() => reject(new Error(`Web startup timed out: ${serverLog.slice(-4000)}`)), 30000);
|
|
148
|
+
const collect = (chunk) => {
|
|
149
|
+
serverLog += chunk.toString();
|
|
150
|
+
const match = serverLog.match(/dsh web: (http:\/\/127\.0\.0\.1:\d+\/\?token=[^\s)]+)/);
|
|
151
|
+
if (match) {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
resolve(match[1]);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
child.stdout.on('data', collect);
|
|
157
|
+
child.stderr.on('data', collect);
|
|
158
|
+
child.once('error', reject);
|
|
159
|
+
});
|
|
160
|
+
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, userDataDir: join(isolated, 'chrome'), args: ['--no-sandbox', '--disable-gpu'] });
|
|
161
|
+
page = await browser.newPage();
|
|
162
|
+
page.setDefaultTimeout(30000);
|
|
163
|
+
page.on('pageerror', (error) => pageErrors.push(error.message));
|
|
164
|
+
await page.goto(url, { waitUntil: 'networkidle2' });
|
|
165
|
+
const dismiss = async () => {
|
|
166
|
+
await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? '')));
|
|
167
|
+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => ['继续', 'Continue'].includes(node.textContent?.trim() ?? ''))?.click());
|
|
168
|
+
};
|
|
169
|
+
await dismiss();
|
|
170
|
+
const registered = await page.evaluate(async (path) => {
|
|
171
|
+
const response = await fetch('/api/workspace/create', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
172
|
+
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'workspace/create', payload: { args: { request: { path } } } }) });
|
|
173
|
+
return response.json();
|
|
174
|
+
}, workspace);
|
|
175
|
+
assert.equal(registered.result?.ok, true, 'register the isolated workspace through public RPC');
|
|
176
|
+
await page.reload({ waitUntil: 'networkidle2' });
|
|
177
|
+
await page.waitForFunction(() => [...document.querySelectorAll('button')].some(node => /Default workspace|默认工作区/.test(node.textContent ?? '')));
|
|
178
|
+
await page.evaluate(() => [...document.querySelectorAll('button')].find(node => /Default workspace|默认工作区/.test(node.textContent ?? ''))?.click());
|
|
179
|
+
await page.waitForFunction((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].some(node => node.textContent?.trim() === title), {}, basename(workspace));
|
|
180
|
+
await page.evaluate((title) => [...document.querySelectorAll('[role="menuitem"], [role="option"], button')].find(node => node.textContent?.trim() === title)?.click(), basename(workspace));
|
|
181
|
+
await page.waitForSelector('[contenteditable="true"]');
|
|
182
|
+
await page.click('[contenteditable="true"]');
|
|
183
|
+
await page.keyboard.type('12月初阿联酋国庆,借5天IRW远程办公和请假回国十几天,去东北长春泡澡,女朋友从南京过去,请探索具体规划。工作时间9-18 CST。');
|
|
184
|
+
await page.click('button[aria-label="发送消息"], button[aria-label="Send message"]');
|
|
185
|
+
await page.waitForFunction(() => /探索稿已保存|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 30000 });
|
|
186
|
+
assert.equal(relayError, undefined);
|
|
187
|
+
assert.ok(existsSync(join(workspace, 'exploration.md')), 'deadline draft really exists in the original workspace');
|
|
188
|
+
const running = ticket();
|
|
189
|
+
assert.equal(running.status, 'running');
|
|
190
|
+
assert.ok(running.jobId);
|
|
191
|
+
await page.screenshot({ path: join(evidence, 'running.png'), fullPage: true });
|
|
192
|
+
await page.waitForFunction(() => /最终规划已完成并展示|RELAY_TEST_FAILURE/.test(document.body.innerText), { timeout: 60000 });
|
|
193
|
+
assert.equal(relayError, undefined);
|
|
194
|
+
const settled = ticket();
|
|
195
|
+
assert.equal(settled.status, 'settled');
|
|
196
|
+
const outputPath = join(ticketDir, settled.deliverableFile);
|
|
197
|
+
const output = readFileSync(outputPath, 'utf8');
|
|
198
|
+
assert.ok(output.includes('Completed synthetic plan') && output.includes(workspace));
|
|
199
|
+
assert.ok(output.includes('9-18 CST'), 'prior constraints travel to the worker');
|
|
200
|
+
assert.ok(!output.includes('DIAGNOSTIC_NOT_A_PLAN'));
|
|
201
|
+
assert.ok(notified);
|
|
202
|
+
assert.deepEqual(pageErrors, []);
|
|
203
|
+
const fileCards = await page.evaluate(() => [...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })));
|
|
204
|
+
const openLabel = await page.evaluate((path) => {
|
|
205
|
+
const button = [...document.querySelectorAll('button')].find(node => [node.getAttribute('title'), node.getAttribute('aria-label'), node.textContent].some(text => text?.includes(path) || text?.includes(path.split('/').at(-1))));
|
|
206
|
+
if (!button)
|
|
207
|
+
return false;
|
|
208
|
+
button.click();
|
|
209
|
+
return true;
|
|
210
|
+
}, outputPath);
|
|
211
|
+
assert.ok(openLabel, `native file card must offer the final output: ${JSON.stringify(fileCards).slice(-4000)}`);
|
|
212
|
+
await page.waitForFunction(() => document.body.innerText.includes('Completed synthetic plan'), { timeout: 30000 });
|
|
213
|
+
await page.screenshot({ path: join(evidence, 'completed-preview.png'), fullPage: true });
|
|
214
|
+
writeFileSync(join(evidence, 'receipt.json'), JSON.stringify({ passed: true, evidence: 'synthetic_fixture',
|
|
215
|
+
runtime: 'source-built DSH Web', layout: 'isolated tarball with repository dependencies', sourceSha, sourceDirty, nodeVersion: process.version, served, notified, ticketId: settled.id, jobId: settled.jobId,
|
|
216
|
+
draftWritten: true, finalPresented: true, previewOpened: true, status: settled.status, pageErrors }, null, 2));
|
|
217
|
+
console.log(`handoff web E2E: OK; synthetic receipt: ${evidence}`);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
if (page) {
|
|
221
|
+
await page.screenshot({ path: join(evidence, 'failure.png'), fullPage: true }).catch(() => { });
|
|
222
|
+
writeFileSync(join(evidence, 'failure-dom.txt'), await page.evaluate(() => document.body.innerText + '\n' + JSON.stringify([...document.querySelectorAll('button')].map(node => ({ text: node.textContent, label: node.getAttribute('aria-label'), title: node.getAttribute('title') })))).catch(() => ''));
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(join(evidence, 'failure.json'), JSON.stringify({ error: String(error), phase, served, pageErrors,
|
|
225
|
+
serverLog: serverLog.replace(/token=[^\s)]+/g, 'token=[redacted]').slice(-5000) }, null, 2));
|
|
226
|
+
writeFileSync(join(evidence, 'requests.json'), JSON.stringify(bodies, null, 2));
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await browser?.close();
|
|
231
|
+
if (child && child.exitCode === null) {
|
|
232
|
+
const closed = new Promise(resolve => child.once('close', () => resolve()));
|
|
233
|
+
child.kill('SIGTERM');
|
|
234
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
|
|
235
|
+
await closed;
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
await new Promise(resolve => relay.close(() => resolve()));
|
|
239
|
+
rmSync(isolated, { recursive: true, force: true });
|
|
240
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
3
|
+
export function registerArtifactDeliveries(ctx) {
|
|
4
|
+
// Tool-registration-only hosts have no conversation event surface.
|
|
5
|
+
if (typeof ctx.on !== 'function')
|
|
6
|
+
return;
|
|
7
|
+
ctx.on('tools/result', (exec, result) => {
|
|
8
|
+
if (result.isError || exec.signal.aborted || !['gotry_itinerary_render', 'gotry_itinerary_deck_render'].includes(exec.name))
|
|
9
|
+
return;
|
|
10
|
+
const value = result.value;
|
|
11
|
+
const session = exec.agent?.session, cwd = session?.header.cwd;
|
|
12
|
+
if (!session || !cwd || !isAbsolute(cwd) || value?.ok !== true || typeof value.path !== 'string' || !isAbsolute(value.path))
|
|
13
|
+
return;
|
|
14
|
+
const boundary = ctx.get('sessionProjections')?.stateOf(session, 'turnBoundary');
|
|
15
|
+
if (!boundary || boundary.openTurnStartSeq === null)
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
const path = realpathSync(value.path), root = realpathSync(cwd), inside = relative(root, path);
|
|
19
|
+
if (inside === '..' || inside.startsWith('..' + sep) || isAbsolute(inside) || !statSync(path).isFile())
|
|
20
|
+
return;
|
|
21
|
+
session.append('deliverables/presented', { turn: boundary.lastTurn, callId: exec.callId,
|
|
22
|
+
files: [{ path: value.path, description: 'GoTry itinerary' }] });
|
|
23
|
+
}
|
|
24
|
+
catch { /* A deleted or escaped file cannot become a delivery. */ }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
import { verifyFlyaiCandidate } from '../../bin/gotry-flyai-verification.js';
|
|
5
|
+
import { listArtifacts } from '../capabilities/artifacts.js';
|
|
6
|
+
import { atomicWriteJson, clearFlyaiKey, displayEndpoint, endpointFingerprint, flyaiConfigPath, isPlaceholderFlyaiKey, maskFlyaiKey, readFlyaiConfig, readFlyaiVerification, removeFlyaiVerification, resolveFlyaiEndpoint, resolveFlyaiKey, saveFlyaiKey, sha256Hex, writeFlyaiVerification, } from '../capabilities/flyai-config.js';
|
|
7
|
+
const json = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
8
|
+
const rejection = (error, status = 400) => json({ ok: false, error }, status);
|
|
9
|
+
const BODY_LIMIT = 8192;
|
|
10
|
+
async function readInput(request) {
|
|
11
|
+
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
|
|
12
|
+
return rejection('需要 JSON 请求。', 415);
|
|
13
|
+
const reader = request.body?.getReader();
|
|
14
|
+
if (!reader)
|
|
15
|
+
return rejection('请求为空。');
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
try {
|
|
19
|
+
while (true) {
|
|
20
|
+
const chunk = await reader.read();
|
|
21
|
+
if (chunk.done)
|
|
22
|
+
break;
|
|
23
|
+
bytes += chunk.value.byteLength;
|
|
24
|
+
if (bytes > BODY_LIMIT) {
|
|
25
|
+
await reader.cancel();
|
|
26
|
+
return rejection('请求过大。', 413);
|
|
27
|
+
}
|
|
28
|
+
chunks.push(chunk.value);
|
|
29
|
+
}
|
|
30
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
31
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : rejection('请求无效。');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return rejection('请求无效。');
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
reader.releaseLock();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function createGotryWebHandlers(options) {
|
|
41
|
+
const homeDir = options.homeDir ?? homedir(), env = options.env ?? process.env;
|
|
42
|
+
const paths = { homeDir, env }, configPath = flyaiConfigPath(homeDir);
|
|
43
|
+
let mutations = Promise.resolve();
|
|
44
|
+
const status = () => {
|
|
45
|
+
const current = resolveFlyaiKey(paths), endpoint = resolveFlyaiEndpoint(paths), receipt = readFlyaiVerification(homeDir);
|
|
46
|
+
const matches = current.key && receipt?.keySha256 === sha256Hex(current.key) && receipt.source === current.source
|
|
47
|
+
&& receipt.endpointFingerprint === endpointFingerprint(endpoint.url) && receipt.endpointDebug === endpoint.debug;
|
|
48
|
+
return { ok: true, configured: Boolean(current.key), verified: Boolean(matches && receipt?.verdict === 'verified'),
|
|
49
|
+
writable: current.source !== 'env' && current.source !== 'env-debug' && process.platform !== 'win32', source: current.source,
|
|
50
|
+
endpoint: displayEndpoint(endpoint.url), endpointDebug: endpoint.debug,
|
|
51
|
+
...(matches && receipt ? { checkedAt: receipt.at, verdict: receipt.verdict } : {}) };
|
|
52
|
+
};
|
|
53
|
+
const bytesAt = () => existsSync(configPath) ? readFileSync(configPath, 'utf8') : undefined;
|
|
54
|
+
async function mutate(input, signal) {
|
|
55
|
+
if (signal.aborted)
|
|
56
|
+
return rejection('操作已取消。', 409);
|
|
57
|
+
if (!status().writable)
|
|
58
|
+
return rejection('当前 Key 由环境变量提供或此平台不支持安全写入。', 409);
|
|
59
|
+
if (input.action === 'clear') {
|
|
60
|
+
const result = clearFlyaiKey(paths);
|
|
61
|
+
if (!result.ok)
|
|
62
|
+
return rejection('无法清除配置,请检查本机配置文件及权限。', 409);
|
|
63
|
+
removeFlyaiVerification(homeDir);
|
|
64
|
+
return json(status());
|
|
65
|
+
}
|
|
66
|
+
const key = typeof input.key === 'string' ? input.key.trim() : '';
|
|
67
|
+
if (!key || key.length > 2048 || /[\r\n\0]/.test(key) || isPlaceholderFlyaiKey(key))
|
|
68
|
+
return rejection('请粘贴控制台中的真实 API Key。');
|
|
69
|
+
const before = readFlyaiConfig(configPath);
|
|
70
|
+
if (!before.ok)
|
|
71
|
+
return rejection('原配置损坏,已保留原文件,请先修复。', 409);
|
|
72
|
+
const previousBytes = bytesAt(), endpoint = resolveFlyaiEndpoint(paths), fingerprint = endpointFingerprint(endpoint.url);
|
|
73
|
+
if (!fingerprint)
|
|
74
|
+
return rejection('当前服务地址无效。', 409);
|
|
75
|
+
const verify = await verifyFlyaiCandidate(key, { env: { ...env, HOME: homeDir }, signal });
|
|
76
|
+
if (signal.aborted || verify.verdict !== 'hit')
|
|
77
|
+
return json({ ok: false, verdict: signal.aborted ? 'cancelled' : verify.verdict,
|
|
78
|
+
error: signal.aborted ? '验证已取消,旧配置保留。' : verify.error ?? '验证失败,旧配置保留。' }, 422);
|
|
79
|
+
if (!status().writable || bytesAt() !== previousBytes || fingerprint !== endpointFingerprint(resolveFlyaiEndpoint(paths).url)) {
|
|
80
|
+
return rejection('配置在验证期间发生变化,请重新读取后再试。', 409);
|
|
81
|
+
}
|
|
82
|
+
const saved = saveFlyaiKey(key, paths);
|
|
83
|
+
if (!saved.ok)
|
|
84
|
+
return rejection('安全保存失败,旧配置保留,请检查本机权限。', 409);
|
|
85
|
+
const receiptSaved = writeFlyaiVerification({ schema: 'gotry.flyai-verification.v1', source: 'config',
|
|
86
|
+
keySha256: sha256Hex(key), maskedKey: maskFlyaiKey(key), verdict: 'verified', endpointFingerprint: fingerprint,
|
|
87
|
+
endpointDebug: endpoint.debug, at: new Date().toISOString() }, homeDir);
|
|
88
|
+
if (!receiptSaved) {
|
|
89
|
+
const restored = previousBytes === undefined
|
|
90
|
+
? (() => { try {
|
|
91
|
+
rmSync(configPath, { force: true });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
} })()
|
|
97
|
+
: atomicWriteJson(configPath, before.data).ok;
|
|
98
|
+
return rejection(restored ? '验证回执保存失败,旧配置已恢复。' : '验证回执保存失败,恢复旧配置失败,请检查本机文件。', 409);
|
|
99
|
+
}
|
|
100
|
+
return json(status());
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
async flyai(request) {
|
|
104
|
+
if (request.method === 'GET')
|
|
105
|
+
return json(status());
|
|
106
|
+
if (request.method !== 'POST')
|
|
107
|
+
return rejection('不支持此操作。', 405);
|
|
108
|
+
const input = await readInput(request);
|
|
109
|
+
if (input instanceof Response)
|
|
110
|
+
return input;
|
|
111
|
+
if (!['save', 'clear'].includes(String(input.action)) || Object.keys(input).some(key => key !== 'action' && key !== 'key')
|
|
112
|
+
|| (input.action === 'clear' && 'key' in input))
|
|
113
|
+
return rejection('请求无效。');
|
|
114
|
+
const operation = mutations.then(() => mutate(input, request.signal)).catch(() => rejection('操作失败,未能确认保存,请重新读取状态。', 500));
|
|
115
|
+
mutations = operation.then(() => undefined);
|
|
116
|
+
return operation;
|
|
117
|
+
},
|
|
118
|
+
async artifacts(request) {
|
|
119
|
+
const query = new URL(request.url).searchParams;
|
|
120
|
+
if ([...query.keys()].some(key => !['sessionId', 'search', 'offset', 'limit'].includes(key)))
|
|
121
|
+
return rejection('查询字段无效。');
|
|
122
|
+
const sessionId = query.get('sessionId') ?? '';
|
|
123
|
+
if (!/^[A-Za-z0-9_-]{1,256}$/.test(sessionId))
|
|
124
|
+
return rejection('需要有效会话。');
|
|
125
|
+
const cwd = await options.workspaceForSession(sessionId);
|
|
126
|
+
if (!cwd || !isAbsolute(cwd))
|
|
127
|
+
return rejection('会话工作目录不可用。', 404);
|
|
128
|
+
try {
|
|
129
|
+
return json({ ok: true, ...await listArtifacts({ stateRoot: options.stateRoot, cwd,
|
|
130
|
+
handoffRoot: env.GOTRY_TURN_HANDOFF_ROOT ?? (options.stateRoot !== '.' ? options.stateRoot : cwd),
|
|
131
|
+
search: query.get('search') ?? undefined, offset: query.has('offset') ? Number(query.get('offset')) : undefined,
|
|
132
|
+
limit: query.has('limit') ? Number(query.get('limit')) : 20 }) });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return rejection('查询参数无效或产物暂时不可读取。');
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
export function registerGotryWebApi(ctx, stateRoot) {
|
|
141
|
+
if (typeof ctx.inject !== 'function')
|
|
142
|
+
return;
|
|
143
|
+
ctx.inject(['connection', 'sessions'], scope => {
|
|
144
|
+
const connection = scope.get('connection');
|
|
145
|
+
const handlers = createGotryWebHandlers({ stateRoot, workspaceForSession: async (sessionId) => {
|
|
146
|
+
const id = sessionId;
|
|
147
|
+
const live = scope.sessions.get(id)?.header;
|
|
148
|
+
const stored = live ? undefined : await scope.get('sessionPersistence')?.stat(sessionId);
|
|
149
|
+
return (live ?? stored?.header)?.cwd;
|
|
150
|
+
} });
|
|
151
|
+
// DSH's streaming adapter always attaches a request body; GET cannot use
|
|
152
|
+
// it. Exact routes have one body mode, so reads and bounded writes differ.
|
|
153
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai', methods: ['GET'], requestBody: 'buffered', fetch: handlers.flyai }));
|
|
154
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/flyai/write', methods: ['POST'], requestBody: 'streaming', fetch: handlers.flyai }));
|
|
155
|
+
scope.effect(() => connection.fetch.register({ path: '/api/gotry/artifacts', methods: ['GET'], requestBody: 'buffered', fetch: handlers.artifacts }));
|
|
156
|
+
});
|
|
157
|
+
}
|