feat: add JavaScript session orchestration runtime
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"runId": "c0f104c0-f80a-4890-86a5-96dd9a44fb78",
|
||||||
|
"sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9",
|
||||||
|
"scriptsDir": "/home/cc-web/.ccweb/scripts",
|
||||||
|
"name": "restart-smoke-20260826.js",
|
||||||
|
"scriptPath": "/home/cc-web/.ccweb/scripts/restart-smoke-20260826.js",
|
||||||
|
"status": "succeeded",
|
||||||
|
"startedAt": "2026-08-26T00:58:42.657Z",
|
||||||
|
"finishedAt": "2026-08-26T00:58:42.864Z",
|
||||||
|
"durationMs": 207,
|
||||||
|
"exitCode": 0,
|
||||||
|
"signal": null,
|
||||||
|
"terminationReason": null,
|
||||||
|
"stopRequested": false,
|
||||||
|
"tokenRevoked": true,
|
||||||
|
"pid": 1181595,
|
||||||
|
"stderrPreview": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"smoke":true,"conversationId":"c01c2e84-5d52-4b0b-bab0-e10daf6c65c9"}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"runId": "caa16aa4-50ac-48fd-9b04-7596f1c4df99",
|
||||||
|
"sourceConversationId": "c01c2e84-5d52-4b0b-bab0-e10daf6c65c9",
|
||||||
|
"scriptsDir": "/home/cc-web/.ccweb/scripts",
|
||||||
|
"name": "session-orchestration-e2e-20260826.js",
|
||||||
|
"scriptPath": "/home/cc-web/.ccweb/scripts/session-orchestration-e2e-20260826.js",
|
||||||
|
"status": "succeeded",
|
||||||
|
"startedAt": "2026-08-26T01:55:38.851Z",
|
||||||
|
"finishedAt": "2026-08-26T01:56:21.027Z",
|
||||||
|
"durationMs": 42176,
|
||||||
|
"exitCode": 0,
|
||||||
|
"signal": null,
|
||||||
|
"terminationReason": null,
|
||||||
|
"stopRequested": false,
|
||||||
|
"tokenRevoked": true,
|
||||||
|
"pid": 1239105,
|
||||||
|
"stderrPreview": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"conversationId":"ca0da1e9-a3b4-46c1-a964-be1d1cb5db1e","branch":"测试成功","message":"已收到"}
|
||||||
8
.ccweb/scripts/package.json
Normal file
8
.ccweb/scripts/package.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ccweb-script-runtime",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@ccweb/session": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.ccweb/scripts/restart-smoke-20260826.js
Normal file
4
.ccweb/scripts/restart-smoke-20260826.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { getCurrentConversationId } from '@ccweb/session';
|
||||||
|
|
||||||
|
const conversationId = await getCurrentConversationId();
|
||||||
|
console.log(JSON.stringify({ smoke: true, conversationId }));
|
||||||
31
.ccweb/scripts/session-orchestration-e2e-20260826.js
Normal file
31
.ccweb/scripts/session-orchestration-e2e-20260826.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
createConversation,
|
||||||
|
sendMessage,
|
||||||
|
selectSemanticBranch,
|
||||||
|
} from '@ccweb/session';
|
||||||
|
|
||||||
|
const conversationId = await createConversation(
|
||||||
|
'这是 JavaScript 会话编排端到端测试。请只回复:测试成功'
|
||||||
|
);
|
||||||
|
|
||||||
|
const branch = await selectSemanticBranch(conversationId, [
|
||||||
|
'测试成功',
|
||||||
|
'测试失败',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (branch !== '测试成功') {
|
||||||
|
const error = new Error(`语义分支未命中测试成功:${branch}`);
|
||||||
|
error.code = 'unexpected_semantic_branch';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await sendMessage(
|
||||||
|
conversationId,
|
||||||
|
'收到。请只回复:已收到'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
conversationId,
|
||||||
|
branch,
|
||||||
|
message,
|
||||||
|
}));
|
||||||
39
.planning/2026-08-26-standard-javascript-session/findings.md
Normal file
39
.planning/2026-08-26-standard-javascript-session/findings.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 标准 JavaScript 会话编排发现
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 提供 `@ccweb/session`,支持 `getCurrentConversationId`、`createConversation`、`sendMessage`、`selectSemanticBranch`、`getLastMessage`。
|
||||||
|
- 脚本位于当前来源对话 cwd 下 `.ccweb/scripts/`,只允许 `.js`,目录使用 ESM。
|
||||||
|
- MCP 提供 create/write/run/get_run/stop 以及 `ccweb_javascript_session_api` manifest。
|
||||||
|
- 异步脚本通过 `runId` 查询;主动 stop 不插入来源消息;异常结束向来源对话插入通知。
|
||||||
|
|
||||||
|
## Research Findings
|
||||||
|
|
||||||
|
- `lib/ccweb-mcp-server.js` 已有 HTTP MCP 客户端,使用 `CC_WEB_MCP_URL`、`CC_WEB_MCP_TOKEN`、`CC_WEB_SOURCE_SESSION_ID`。
|
||||||
|
- `server.js` 的 `/api/internal/mcp` 会鉴权后调用 `callInternalMcpTool`。
|
||||||
|
- 现有会话入口为 `createMcpConversation`、`sendCrossConversationMessage` 和 `handleCodexAppSteerMessage`。
|
||||||
|
- Codex App 完成路径 `handleCodexAppTurnComplete` 会持久化 assistant 消息并完成跨会话回复;需要复用其完成点实现脚本等待。
|
||||||
|
- `killProcess` 与 `handleCodexAppAbortSession` 可作为停止行为的参考,但脚本子进程需要独立 PID/日志/状态管理。
|
||||||
|
- codebase-memory 项目 `home-cc-web` 索引状态为 ready(7740 nodes/17835 edges)。
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| 脚本包通过本地 `node_modules/@ccweb/session` 注入 | Node ESM 对裸包名解析稳定,不依赖不可靠的 `NODE_PATH` |
|
||||||
|
| 脚本运行凭据按 runId 绑定 | 避免把全局内部 MCP token 长期暴露给用户脚本 |
|
||||||
|
| 日志追加落盘,get_run 分段读取 | 支持用户确认的无限输出,同时保护 MCP 响应大小 |
|
||||||
|
| 判断器一次性只读 Codex App turn | 不污染目标会话、工作区和普通 MCP 能力 |
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| 根目录计划属于此前 hooks 验证任务 | 使用 scoped plan,保留原有计划文件 |
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- `server.js`
|
||||||
|
- `lib/ccweb-mcp-server.js`
|
||||||
|
- `lib/codex-app-server-client.js`
|
||||||
|
- `.codex/skills/planning-with-files/`
|
||||||
85
.planning/2026-08-26-standard-javascript-session/progress.md
Normal file
85
.planning/2026-08-26-standard-javascript-session/progress.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 标准 JavaScript 会话编排进度
|
||||||
|
|
||||||
|
## Session: 2026-08-26
|
||||||
|
|
||||||
|
### Phase 1:需求与代码链路确认
|
||||||
|
|
||||||
|
- **Status:** complete
|
||||||
|
- **Started:** 2026-08-26
|
||||||
|
- Actions taken:
|
||||||
|
- 完成 grilling 需求对齐,共确认 23 项决策。
|
||||||
|
- 读取项目 AGENTS.md 和相关技能说明。
|
||||||
|
- 使用 codebase-memory 确认索引 ready,并核对 MCP/会话/steer 入口。
|
||||||
|
- 创建本任务 scoped planning 文件。
|
||||||
|
- Files created/modified:
|
||||||
|
- `.planning/2026-08-26-standard-javascript-session/task_plan.md`
|
||||||
|
- `.planning/2026-08-26-standard-javascript-session/findings.md`
|
||||||
|
- `.planning/2026-08-26-standard-javascript-session/progress.md`
|
||||||
|
|
||||||
|
### Phase 2:MCP 契约与脚本运行基础
|
||||||
|
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- 新增 6 个脚本 MCP 工具定义,并接入 stdio/HTTP `tools/list`。
|
||||||
|
- 在来源对话 cwd 下创建 `.ccweb/scripts/`,注入根 `package.json` 和 `node_modules/@ccweb/session` ESM 包。
|
||||||
|
- 增加脚本名、符号链接、脚本源代码大小校验和原子写入。
|
||||||
|
- 增加独立 Node 子进程、run.json、stdout/stderr 完整落盘、尾部/范围读取、重复启动拒绝、优雅停止/强制终止和重启恢复。
|
||||||
|
- Files created/modified:
|
||||||
|
- `lib/javascript-session-runtime.js`
|
||||||
|
- `lib/ccweb-mcp-server.js`
|
||||||
|
- `server.js`
|
||||||
|
|
||||||
|
### Phase 3:会话标准包与服务端编排
|
||||||
|
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- 标准包 5 个函数通过短期 run token 调用现有 ccweb `/api/internal/mcp`,不重复实现会话。
|
||||||
|
- 创建/发送服务端等待目标助手消息完成;当前 Codex App 对话复用 steer。
|
||||||
|
- 语义分支使用一次性只读 Codex App 判断 turn,严格候选匹配,最多重试 3 次。
|
||||||
|
- 脚本异常结束/服务重启向来源对话插入带 runId、状态、原因和 stderr 尾部的通知;主动 stop 不插入。
|
||||||
|
- Files created/modified:
|
||||||
|
- `lib/javascript-session-runtime.js`
|
||||||
|
- `server.js`
|
||||||
|
|
||||||
|
### Phase 4:测试与回归
|
||||||
|
|
||||||
|
- **Status:** complete
|
||||||
|
- Actions taken:
|
||||||
|
- 新增 `scripts/javascript-session-runtime-unit.js`,覆盖 ESM 裸包导入、目录边界、重复启动、状态查询、异常通知、主动停止和重启恢复。
|
||||||
|
- 真实服务验证 `tools/list`、API manifest、脚本执行和 `getCurrentConversationId()`。
|
||||||
|
- 修复 Node 18 对 node_modules ESM 包边界的解析问题。
|
||||||
|
- Test results:
|
||||||
|
- `node scripts/javascript-session-runtime-unit.js`:通过
|
||||||
|
- `node scripts/regression.js`:通过
|
||||||
|
- `node --check server.js`:通过
|
||||||
|
- `node --check lib/javascript-session-runtime.js`:通过
|
||||||
|
- `node --check lib/ccweb-mcp-server.js`:通过
|
||||||
|
- `git diff --check`:通过
|
||||||
|
- 项目回归首次运行遇到既有 Codex App steer 模拟的时序抖动,立即重跑通过;未产生源码或运行态残留。
|
||||||
|
|
||||||
|
### Phase 5:交付
|
||||||
|
|
||||||
|
- **Status:** complete
|
||||||
|
- 已清理根目录临时脚本测试产物和 TODO CSV;保留源码、单测与 scoped planning 记录。
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
| Test | Input | Expected | Actual | Status |
|
||||||
|
|------|-------|----------|--------|--------|
|
||||||
|
| 代码索引状态 | `home-cc-web` | ready | ready | ✓ |
|
||||||
|
|
||||||
|
## Error Log
|
||||||
|
|
||||||
|
| Timestamp | Error | Attempt | Resolution |
|
||||||
|
|-----------|-------|---------|------------|
|
||||||
|
| 2026-08-26 | 用户级 planning-with-files 路径不存在 | 1 | 改用项目内技能路径 |
|
||||||
|
|
||||||
|
## 5-Question Reboot Check
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|----------|--------|
|
||||||
|
| Where am I? | Phase 2:MCP 契约与脚本运行基础 |
|
||||||
|
| Where am I going? | 完成脚本工具、运行注册表、标准包和回归 |
|
||||||
|
| What's the goal? | 为 cc-web 提供标准 JavaScript 会话编排能力 |
|
||||||
|
| What have I learned? | 现有 MCP/会话/steer 入口可复用,完成点需接入服务端等待 |
|
||||||
|
| What have I done? | 完成需求收敛、代码索引核验和 scoped planning 文件 |
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# 标准 JavaScript 会话编排实施计划
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
为 cc-web 增加受控的 JavaScript 脚本编排能力:通过 MCP 创建、写入、异步启动和停止脚本,并注入 `@ccweb/session` 标准包,让脚本 Promise 化调用现有 ccweb 会话。
|
||||||
|
|
||||||
|
## Current Phase
|
||||||
|
|
||||||
|
Phase 5:交付收尾
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 1:需求与代码链路确认
|
||||||
|
- [x] 汇总已确认的 API、脚本生命周期、异常通知和错误协议
|
||||||
|
- [x] 使用 codebase-memory 确认现有 MCP、会话创建、消息发送和 steer 入口
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 2:MCP 契约与脚本运行基础
|
||||||
|
- [x] 新增脚本 MCP 工具定义和 API manifest;验收:tools/list 能发现 create/write/run/get_run/stop/api 工具,工具输入 schema 和 manifest 可 JSON 序列化
|
||||||
|
- [x] 新增脚本目录、ESM 标准包注入和路径校验;验收:只能访问当前 cwd/.ccweb/scripts 下的 .js,包可被裸名 import
|
||||||
|
- [x] 新增异步脚本运行注册表、日志落盘、停止和重启恢复;验收:runId 查询、stdout/stderr 分段读取、stop 吊销凭据、重启标记 killed
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 3:会话标准包与服务端编排
|
||||||
|
- [x] 实现 Promise 化的当前会话、创建、发送、最后消息和语义分支能力;验收:每个 API 返回约定字符串/ID,错误携带稳定 code/details
|
||||||
|
- [x] 接入现有 ccweb 会话状态、Codex App steer 和异常来源会话通知;验收:同对话复用 steer,不重复运行同脚本,异常才插入来源通知
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 4:测试与回归
|
||||||
|
- [x] 编写脚本 MCP、标准包和异常通知回归测试
|
||||||
|
- [x] 运行语法检查、单元回归和项目 regression
|
||||||
|
- [x] 修复发现的问题并记录验证结果
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
### Phase 5:交付
|
||||||
|
- [x] 清理临时 TODO CSV 和本计划状态
|
||||||
|
- [x] 汇总变更、测试结果、风险和未覆盖边界
|
||||||
|
- **Status:** complete
|
||||||
|
|
||||||
|
## Key Questions
|
||||||
|
|
||||||
|
1. 如何在不让脚本轮询的情况下等待现有会话完成?——由服务端等待会话状态和持久化消息变化。
|
||||||
|
2. 如何让同对话消息复用现有 steer,且不重复启动同一脚本?——调用现有 handleMessage/steer,并按来源+脚本路径拒绝并发重复运行。
|
||||||
|
3. 如何在脚本异常结束时通知来源对话,同时区分主动 stop?——运行注册表记录 stopRequested,只有非主动失败插入通知。
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| 独立 Node 子进程运行脚本 | 隔离脚本异常并支持异步执行 |
|
||||||
|
| `.js` + `type=module`,注入 `@ccweb/session` | 保持用户示例并支持显式 ESM import/顶层 await |
|
||||||
|
| 服务端实现 Promise 等待 | 脚本不实现监听、轮询和等待封装 |
|
||||||
|
| 同对话发送复用现有 steer/插入 | 避免并行 turn 和递归死锁 |
|
||||||
|
| 运行记录和 stdout/stderr 持久化 | 支持异步 runId 查询、重启标记和长输出分段读取 |
|
||||||
|
| 主动 stop 不插入来源对话 | MCP 成功/失败结果已经是调用反馈 |
|
||||||
|
| 非正常结束插入来源对话通知 | 让启动脚本的 Agent 感知异常并继续处理 |
|
||||||
|
|
||||||
|
## Errors Encountered
|
||||||
|
|
||||||
|
| Error | Attempt | Resolution |
|
||||||
|
|-------|---------|------------|
|
||||||
|
| 用户级 planning-with-files 技能路径不存在 | 1 | 改用项目内 `.codex/skills/planning-with-files/` 版本 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- 不覆盖根目录已有的旧 `task_plan.md`、`findings.md`、`progress.md`,本任务使用 scoped planning 目录。
|
||||||
|
- 按已确认需求不设置运行时长、输出和并发硬上限;完整日志落盘,单次 get_run 只返回尾部或显式范围,避免无限响应。
|
||||||
|
- 脚本按受信代码执行,不伪造 JavaScript 沙箱;仍限制脚本路径、MCP 凭据生命周期和运行记录归属。
|
||||||
Binary file not shown.
@@ -5,6 +5,7 @@ const http = require('http');
|
|||||||
const https = require('https');
|
const https = require('https');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { SCRIPT_TOOL_DEFINITIONS } = require('./javascript-session-runtime');
|
||||||
|
|
||||||
const SERVER_INFO = {
|
const SERVER_INFO = {
|
||||||
name: 'ccweb',
|
name: 'ccweb',
|
||||||
@@ -28,6 +29,11 @@ const CODEX_APP_COMMUNICATION_TOOL_NAMES = new Set([
|
|||||||
const HIDDEN_CALLABLE_TOOL_NAMES = new Set([
|
const HIDDEN_CALLABLE_TOOL_NAMES = new Set([
|
||||||
'ccweb_request_reply',
|
'ccweb_request_reply',
|
||||||
'ccweb_task_update',
|
'ccweb_task_update',
|
||||||
|
'ccweb_script_get_current_conversation_id',
|
||||||
|
'ccweb_script_create_conversation',
|
||||||
|
'ccweb_script_send_message',
|
||||||
|
'ccweb_script_select_semantic_branch',
|
||||||
|
'ccweb_script_get_last_message',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
@@ -307,6 +313,7 @@ const TOOLS = [
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...SCRIPT_TOOL_DEFINITIONS,
|
||||||
];
|
];
|
||||||
|
|
||||||
function imageMimeFromPath(filePath) {
|
function imageMimeFromPath(filePath) {
|
||||||
|
|||||||
705
lib/javascript-session-runtime.js
Normal file
705
lib/javascript-session-runtime.js
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const http = require('http');
|
||||||
|
const https = require('https');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PACKAGE_NAME = '@ccweb/session';
|
||||||
|
const PACKAGE_VERSION = '1.0.0';
|
||||||
|
const DEFAULT_LOG_TAIL_BYTES = 64 * 1024;
|
||||||
|
const MAX_SCRIPT_SOURCE_BYTES = 1024 * 1024;
|
||||||
|
const DEFAULT_RUN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const SCRIPT_TOOL_NAMES = Object.freeze([
|
||||||
|
'ccweb_create_javascript_script',
|
||||||
|
'ccweb_write_javascript_script',
|
||||||
|
'ccweb_run_javascript_script',
|
||||||
|
'ccweb_get_javascript_script_run',
|
||||||
|
'ccweb_stop_javascript_script',
|
||||||
|
'ccweb_javascript_session_api',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SCRIPT_SESSION_TOOL_NAMES = Object.freeze([
|
||||||
|
'ccweb_script_get_current_conversation_id',
|
||||||
|
'ccweb_script_create_conversation',
|
||||||
|
'ccweb_script_send_message',
|
||||||
|
'ccweb_script_select_semantic_branch',
|
||||||
|
'ccweb_script_get_last_message',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SCRIPT_TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
name: 'ccweb_create_javascript_script',
|
||||||
|
description: '在当前来源对话 cwd 下的 .ccweb/scripts/ 创建一个新的 JavaScript ESM 脚本文件。仅允许相对 .js 文件名;文件已存在时返回 script_exists。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { name: { type: 'string', description: '脚本相对路径,必须以 .js 结尾且不能路径穿越。' } },
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_write_javascript_script',
|
||||||
|
description: '原子覆盖当前来源对话 cwd 下 .ccweb/scripts/ 内的 JavaScript 脚本内容;脚本使用 ESM。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string', description: '脚本相对路径,必须以 .js 结尾。' },
|
||||||
|
content: { type: 'string', description: '完整脚本内容。' },
|
||||||
|
},
|
||||||
|
required: ['name', 'content'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_run_javascript_script',
|
||||||
|
description: '异步启动当前来源对话 .ccweb/scripts/ 内的 JavaScript 脚本,立即返回 runId;使用 ccweb_get_javascript_script_run 查询结果,使用 ccweb_stop_javascript_script 停止。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { name: { type: 'string', description: '脚本相对路径。' } },
|
||||||
|
required: ['name'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_get_javascript_script_run',
|
||||||
|
description: '查询脚本 runId 的状态和日志。默认返回 stdout/stderr 尾部;可用 stream、offset、limit 分段读取完整日志。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
runId: { type: 'string' },
|
||||||
|
stream: { type: 'string', enum: ['stdout', 'stderr'] },
|
||||||
|
offset: { type: 'integer', minimum: 0 },
|
||||||
|
limit: { type: 'integer', minimum: 1 },
|
||||||
|
},
|
||||||
|
required: ['runId'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_stop_javascript_script',
|
||||||
|
description: '主动停止脚本。先优雅终止,2 秒后仍未退出则强制终止;主动停止不向来源对话插入通知。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { runId: { type: 'string' } },
|
||||||
|
required: ['runId'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_javascript_session_api',
|
||||||
|
description: '返回 @ccweb/session 标准包、Promise 会话函数和 JavaScript 脚本 MCP 工具的完整结构化使用说明。',
|
||||||
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function stableError(code, message, details = {}) {
|
||||||
|
return { ok: false, code, message, ...details };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorFromPayload(payload) {
|
||||||
|
const error = new Error(String(payload?.message || payload?.code || 'ccweb 脚本调用失败'));
|
||||||
|
error.code = String(payload?.code || 'script_call_failed');
|
||||||
|
error.details = payload && typeof payload === 'object' ? { ...payload } : {};
|
||||||
|
delete error.details.ok;
|
||||||
|
delete error.details.code;
|
||||||
|
delete error.details.message;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageIndexSource() {
|
||||||
|
return `import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
|
||||||
|
const DEFAULT_URL = process.env.CC_WEB_SCRIPT_MCP_URL || process.env.CC_WEB_MCP_URL || '';
|
||||||
|
const RUN_ID = process.env.CC_WEB_SCRIPT_RUN_ID || '';
|
||||||
|
const TOKEN = process.env.CC_WEB_SCRIPT_MCP_TOKEN || '';
|
||||||
|
const SOURCE_ID = process.env.CC_WEB_SOURCE_SESSION_ID || '';
|
||||||
|
|
||||||
|
function call(tool, args = {}) {
|
||||||
|
const urlText = DEFAULT_URL;
|
||||||
|
if (!urlText || !TOKEN || !RUN_ID || !SOURCE_ID) {
|
||||||
|
const error = new Error('ccweb JavaScript 会话包运行上下文不完整。');
|
||||||
|
error.code = 'script_context_missing';
|
||||||
|
error.details = { tool };
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
let url;
|
||||||
|
try { url = new URL(urlText); } catch (cause) {
|
||||||
|
const error = new Error('ccweb JavaScript 会话 MCP 地址无效。');
|
||||||
|
error.code = 'script_mcp_bad_url';
|
||||||
|
error.details = { cause: cause?.message || String(cause || '') };
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
const body = JSON.stringify({ tool, args, sourceSessionId: SOURCE_ID, scriptRunId: RUN_ID });
|
||||||
|
const transport = url.protocol === 'https:' ? https : http;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = transport.request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'X-CC-Web-MCP-Token': TOKEN,
|
||||||
|
},
|
||||||
|
}, (response) => {
|
||||||
|
let data = '';
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
response.on('data', (chunk) => { data += chunk; });
|
||||||
|
response.on('end', () => {
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(data || '{}'); } catch (cause) {
|
||||||
|
const error = new Error('ccweb JavaScript 会话 MCP 返回了无效 JSON。');
|
||||||
|
error.code = 'script_mcp_bad_response';
|
||||||
|
error.details = { statusCode: response.statusCode, cause: cause?.message || String(cause || '') };
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300 || payload?.ok === false) {
|
||||||
|
const error = errorFromPayload(payload);
|
||||||
|
error.details = { ...error.details, statusCode: response.statusCode };
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
request.on('error', (cause) => {
|
||||||
|
const error = new Error(cause?.message || 'ccweb JavaScript 会话 MCP 请求失败。');
|
||||||
|
error.code = 'script_mcp_request_failed';
|
||||||
|
error.details = { tool };
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
request.write(body);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentConversationId() {
|
||||||
|
const result = await call('ccweb_script_get_current_conversation_id');
|
||||||
|
return result.conversationId;
|
||||||
|
}
|
||||||
|
export async function createConversation(prompt) {
|
||||||
|
const result = await call('ccweb_script_create_conversation', { prompt });
|
||||||
|
return result.conversationId;
|
||||||
|
}
|
||||||
|
export async function sendMessage(conversationId, prompt) {
|
||||||
|
const result = await call('ccweb_script_send_message', { conversationId, prompt });
|
||||||
|
return result.message;
|
||||||
|
}
|
||||||
|
export async function selectSemanticBranch(conversationId, semantics) {
|
||||||
|
const result = await call('ccweb_script_select_semantic_branch', { conversationId, semantics });
|
||||||
|
return result.branch;
|
||||||
|
}
|
||||||
|
export async function getLastMessage(conversationId) {
|
||||||
|
const result = await call('ccweb_script_get_last_message', { conversationId });
|
||||||
|
return result.message;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageJsonSource() {
|
||||||
|
return JSON.stringify({ name: 'ccweb-script-runtime', private: true, type: 'module', dependencies: { [PACKAGE_NAME]: PACKAGE_VERSION } }, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageModuleJsonSource() {
|
||||||
|
return JSON.stringify({ name: PACKAGE_NAME, version: PACKAGE_VERSION, private: true, type: 'module', main: './index.js', exports: './index.js' }, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createJavascriptSessionRuntime(deps = {}) {
|
||||||
|
const activeRuns = new Map();
|
||||||
|
const sessionsDir = deps.sessionsDir || process.cwd();
|
||||||
|
const runRetentionMs = Number.isFinite(Number(deps.runRetentionMs)) && Number(deps.runRetentionMs) > 0
|
||||||
|
? Number(deps.runRetentionMs)
|
||||||
|
: DEFAULT_RUN_RETENTION_MS;
|
||||||
|
|
||||||
|
function sourceSession(sourceSessionId) {
|
||||||
|
const id = String(sourceSessionId || '').trim();
|
||||||
|
const session = typeof deps.loadSession === 'function' ? deps.loadSession(id) : null;
|
||||||
|
return session ? { ok: true, id, session } : stableError('source_not_found', '来源对话不存在。', { sourceConversationId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptsDirFor(sourceSessionId, create = false) {
|
||||||
|
const source = sourceSession(sourceSessionId);
|
||||||
|
if (!source.ok) return source;
|
||||||
|
const cwd = path.resolve(source.session.cwd || process.cwd());
|
||||||
|
const scriptsDir = path.join(cwd, '.ccweb', 'scripts');
|
||||||
|
if (create) {
|
||||||
|
try { fs.mkdirSync(path.join(scriptsDir, 'node_modules', '@ccweb', 'session'), { recursive: true }); } catch (error) {
|
||||||
|
return stableError('script_directory_failed', `无法准备脚本目录:${error.message}`, { sourceConversationId: source.id });
|
||||||
|
}
|
||||||
|
const packageJsonPath = path.join(scriptsDir, 'package.json');
|
||||||
|
const modulePackageJsonPath = path.join(scriptsDir, 'node_modules', '@ccweb', 'session', 'package.json');
|
||||||
|
const packageIndexPath = path.join(scriptsDir, 'node_modules', '@ccweb', 'session', 'index.js');
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(packageJsonPath)) fs.writeFileSync(packageJsonPath, packageJsonSource(), { flag: 'wx' });
|
||||||
|
if (!fs.existsSync(modulePackageJsonPath)) fs.writeFileSync(modulePackageJsonPath, packageModuleJsonSource(), { flag: 'wx' });
|
||||||
|
if (!fs.existsSync(packageIndexPath)) fs.writeFileSync(packageIndexPath, packageIndexSource(), { flag: 'wx' });
|
||||||
|
} catch (error) {
|
||||||
|
return stableError('script_package_failed', `无法注入 ${PACKAGE_NAME}:${error.message}`, { sourceConversationId: source.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, id: source.id, session: source.session, scriptsDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScriptPath(sourceSessionId, rawName, options = {}) {
|
||||||
|
const name = String(rawName || '').trim().replace(/\\/g, '/');
|
||||||
|
if (!name || name.startsWith('/') || name.includes('\0') || path.posix.isAbsolute(name) || name.split('/').includes('..') || !name.endsWith('.js')) {
|
||||||
|
return stableError('invalid_script_name', '脚本名必须是 .ccweb/scripts/ 下的相对 .js 路径。', { name });
|
||||||
|
}
|
||||||
|
const root = scriptsDirFor(sourceSessionId, options.create === true);
|
||||||
|
if (!root.ok) return root;
|
||||||
|
const resolved = path.resolve(root.scriptsDir, ...name.split('/'));
|
||||||
|
if (resolved !== root.scriptsDir && !resolved.startsWith(`${root.scriptsDir}${path.sep}`)) {
|
||||||
|
return stableError('invalid_script_path', '脚本路径越界。', { name });
|
||||||
|
}
|
||||||
|
if (!options.allowNested && path.dirname(path.relative(root.scriptsDir, resolved)) !== '.') {
|
||||||
|
return stableError('invalid_script_name', '第一版只允许脚本目录下的相对文件名。', { name });
|
||||||
|
}
|
||||||
|
return { ...root, name, scriptPath: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDir(entry) {
|
||||||
|
return path.join(entry.scriptsDir, '.runs', entry.runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataPath(entry) { return path.join(runDir(entry), 'run.json'); }
|
||||||
|
function stdoutPath(entry) { return path.join(runDir(entry), 'stdout.log'); }
|
||||||
|
function stderrPath(entry) { return path.join(runDir(entry), 'stderr.log'); }
|
||||||
|
|
||||||
|
function persist(entry) {
|
||||||
|
const dir = runDir(entry);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const record = { ...entry };
|
||||||
|
delete record.process;
|
||||||
|
delete record.token;
|
||||||
|
delete record.stdoutStream;
|
||||||
|
delete record.stderrStream;
|
||||||
|
delete record.forceKillTimer;
|
||||||
|
delete record.finishing;
|
||||||
|
const temp = `${metadataPath(entry)}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
|
||||||
|
fs.renameSync(temp, metadataPath(entry));
|
||||||
|
} catch (error) {
|
||||||
|
try { fs.unlinkSync(temp); } catch {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tailFile(filePath, limit = DEFAULT_LOG_TAIL_BYTES) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const size = stat.size;
|
||||||
|
const start = Math.max(0, size - limit);
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
const buffer = Buffer.alloc(size - start);
|
||||||
|
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return { text: buffer.toString('utf8'), size, offset: start, truncated: start > 0 };
|
||||||
|
} catch { return { text: '', size: 0, offset: 0, truncated: false }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFile(filePath, offset = 0, limit = DEFAULT_LOG_TAIL_BYTES) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
const safeOffset = Math.max(0, Math.min(stat.size, Number.parseInt(String(offset), 10) || 0));
|
||||||
|
const safeLimit = Math.max(1, Math.min(1024 * 1024, Number.parseInt(String(limit), 10) || DEFAULT_LOG_TAIL_BYTES));
|
||||||
|
const length = Math.min(safeLimit, stat.size - safeOffset);
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
const buffer = Buffer.alloc(length);
|
||||||
|
if (length > 0) fs.readSync(fd, buffer, 0, length, safeOffset);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return { text: buffer.toString('utf8'), size: stat.size, offset: safeOffset, limit: safeLimit, truncated: safeOffset + length < stat.size };
|
||||||
|
} catch { return { text: '', size: 0, offset: 0, limit, truncated: false }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRecordForSource(sourceSessionId, runId) {
|
||||||
|
const id = String(runId || '').trim();
|
||||||
|
if (!id || !/^[0-9a-f-]{20,}$/i.test(id)) return stableError('invalid_run_id', 'runId 无效。', { runId: id });
|
||||||
|
const root = scriptsDirFor(sourceSessionId, false);
|
||||||
|
if (!root.ok) return root;
|
||||||
|
const metadata = path.join(root.scriptsDir, '.runs', id, 'run.json');
|
||||||
|
if (!fs.existsSync(metadata)) return stableError('script_run_not_found', '未找到脚本运行记录。', { runId: id });
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(fs.readFileSync(metadata, 'utf8'));
|
||||||
|
if (record.sourceConversationId !== root.id) return stableError('script_run_forbidden', '无权访问该脚本运行记录。', { runId: id });
|
||||||
|
return { ok: true, record, scriptsDir: root.scriptsDir, metadata };
|
||||||
|
} catch (error) {
|
||||||
|
return stableError('script_run_corrupt', `脚本运行记录损坏:${error.message}`, { runId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScript(args = {}, sourceSessionId = '') {
|
||||||
|
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(resolved.scriptPath), { recursive: true });
|
||||||
|
const stat = fs.existsSync(resolved.scriptPath) ? fs.lstatSync(resolved.scriptPath) : null;
|
||||||
|
if (stat) return stableError('script_exists', '脚本文件已存在。', { name: resolved.name, path: resolved.scriptPath });
|
||||||
|
fs.writeFileSync(resolved.scriptPath, '', { flag: 'wx' });
|
||||||
|
return { ok: true, name: resolved.name, path: resolved.scriptPath, scriptsDir: resolved.scriptsDir };
|
||||||
|
} catch (error) {
|
||||||
|
return stableError('script_create_failed', `创建脚本失败:${error.message}`, { name: resolved.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeScript(args = {}, sourceSessionId = '') {
|
||||||
|
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
const content = typeof args.content === 'string' ? args.content : '';
|
||||||
|
const contentBytes = Buffer.byteLength(content, 'utf8');
|
||||||
|
if (contentBytes > MAX_SCRIPT_SOURCE_BYTES) {
|
||||||
|
return stableError('script_content_too_large', '脚本内容不能超过 1 MiB。', {
|
||||||
|
name: resolved.name,
|
||||||
|
bytes: contentBytes,
|
||||||
|
maxBytes: MAX_SCRIPT_SOURCE_BYTES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(resolved.scriptPath) && fs.lstatSync(resolved.scriptPath).isSymbolicLink()) {
|
||||||
|
return stableError('script_symlink_forbidden', '不允许写入符号链接脚本。', { name: resolved.name });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(resolved.scriptPath), { recursive: true });
|
||||||
|
const temp = `${resolved.scriptPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(temp, content);
|
||||||
|
fs.renameSync(temp, resolved.scriptPath);
|
||||||
|
} catch (error) {
|
||||||
|
try { fs.unlinkSync(temp); } catch {}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { ok: true, name: resolved.name, path: resolved.scriptPath, bytes: Buffer.byteLength(content) };
|
||||||
|
} catch (error) {
|
||||||
|
return stableError('script_write_failed', `写入脚本失败:${error.message}`, { name: resolved.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRun(entry, code, signal, forcedReason = '') {
|
||||||
|
if (!entry || entry.finishedAt || entry.finishing) return;
|
||||||
|
entry.finishing = true;
|
||||||
|
if (entry.forceKillTimer) {
|
||||||
|
clearTimeout(entry.forceKillTimer);
|
||||||
|
entry.forceKillTimer = null;
|
||||||
|
}
|
||||||
|
entry.exitCode = typeof code === 'number' ? code : null;
|
||||||
|
entry.signal = signal || null;
|
||||||
|
entry.finishedAt = new Date().toISOString();
|
||||||
|
entry.durationMs = Math.max(0, new Date(entry.finishedAt).getTime() - new Date(entry.startedAt).getTime());
|
||||||
|
entry.terminationReason = forcedReason || entry.terminationReason || null;
|
||||||
|
if (entry.stopRequested) entry.status = 'killed';
|
||||||
|
else entry.status = code === 0 && !signal ? 'succeeded' : 'failed';
|
||||||
|
entry.tokenRevoked = true;
|
||||||
|
let streamsToClose = 0;
|
||||||
|
let finalized = false;
|
||||||
|
const finalize = () => {
|
||||||
|
if (finalized) return;
|
||||||
|
finalized = true;
|
||||||
|
entry.finishing = false;
|
||||||
|
entry.stderrPreview = tailFile(stderrPath(entry), 8 * 1024).text;
|
||||||
|
activeRuns.delete(entry.runId);
|
||||||
|
persist(entry);
|
||||||
|
if (entry.status !== 'succeeded' && !entry.stopRequested && typeof deps.notifyFailure === 'function') {
|
||||||
|
try { deps.notifyFailure(entry.sourceConversationId, entry); } catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const streamClosed = () => {
|
||||||
|
streamsToClose -= 1;
|
||||||
|
if (streamsToClose <= 0) finalize();
|
||||||
|
};
|
||||||
|
for (const stream of [entry.stdoutStream, entry.stderrStream]) {
|
||||||
|
if (!stream) continue;
|
||||||
|
streamsToClose += 1;
|
||||||
|
stream.end(streamClosed);
|
||||||
|
}
|
||||||
|
if (streamsToClose === 0) finalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runScript(args = {}, sourceSessionId = '') {
|
||||||
|
const resolved = resolveScriptPath(sourceSessionId, args.name, { create: true });
|
||||||
|
if (!resolved.ok) return resolved;
|
||||||
|
if (!fs.existsSync(resolved.scriptPath)) return stableError('script_not_found', '脚本文件不存在,请先创建或写入脚本。', { name: resolved.name });
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(resolved.scriptPath);
|
||||||
|
if (stat.isSymbolicLink()) return stableError('script_symlink_forbidden', '不允许执行符号链接脚本。', { name: resolved.name });
|
||||||
|
if (!stat.isFile()) return stableError('script_not_file', '脚本路径必须指向普通文件。', { name: resolved.name });
|
||||||
|
} catch (error) {
|
||||||
|
return stableError('script_not_found', `无法读取脚本文件:${error.message}`, { name: resolved.name });
|
||||||
|
}
|
||||||
|
if (activeRunsHasSourceScript(sourceSessionId, resolved.scriptPath)) {
|
||||||
|
return stableError('script_already_running', '同一脚本已经在运行中。', { name: resolved.name });
|
||||||
|
}
|
||||||
|
const runId = crypto.randomUUID();
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const entry = {
|
||||||
|
runId,
|
||||||
|
sourceConversationId: resolved.id,
|
||||||
|
scriptsDir: resolved.scriptsDir,
|
||||||
|
name: resolved.name,
|
||||||
|
scriptPath: resolved.scriptPath,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
exitCode: null,
|
||||||
|
signal: null,
|
||||||
|
terminationReason: null,
|
||||||
|
stopRequested: false,
|
||||||
|
tokenRevoked: false,
|
||||||
|
pid: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(resolved.scriptsDir, '.runs', runId), { recursive: true });
|
||||||
|
entry.stdoutStream = fs.createWriteStream(stdoutPath(entry), { flags: 'a' });
|
||||||
|
entry.stderrStream = fs.createWriteStream(stderrPath(entry), { flags: 'a' });
|
||||||
|
entry.token = token;
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
CC_WEB_SCRIPT_MCP_URL: String(deps.internalMcpUrl || ''),
|
||||||
|
CC_WEB_SCRIPT_MCP_TOKEN: token,
|
||||||
|
CC_WEB_SCRIPT_RUN_ID: runId,
|
||||||
|
CC_WEB_SOURCE_SESSION_ID: resolved.id,
|
||||||
|
};
|
||||||
|
const child = spawn(process.execPath, [resolved.scriptPath], {
|
||||||
|
cwd: resolved.scriptsDir,
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
entry.process = child;
|
||||||
|
entry.pid = child.pid;
|
||||||
|
child.stdout.pipe(entry.stdoutStream);
|
||||||
|
child.stderr.pipe(entry.stderrStream);
|
||||||
|
activeRuns.set(runId, entry);
|
||||||
|
persist(entry);
|
||||||
|
child.on('error', (error) => finishRun(entry, null, null, `spawn_error:${error.message}`));
|
||||||
|
// close 事件发生在 stdout/stderr 流关闭后,确保 stderrPreview 已经包含最后输出。
|
||||||
|
child.on('close', (code, signal) => finishRun(entry, code, signal));
|
||||||
|
return { ok: true, runId, name: resolved.name, status: entry.status, startedAt: entry.startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
entry.status = 'failed';
|
||||||
|
entry.finishedAt = new Date().toISOString();
|
||||||
|
entry.terminationReason = `spawn_error:${error.message}`;
|
||||||
|
entry.tokenRevoked = true;
|
||||||
|
try { persist(entry); } catch {}
|
||||||
|
if (entry.stdoutStream) entry.stdoutStream.end();
|
||||||
|
if (entry.stderrStream) entry.stderrStream.end();
|
||||||
|
if (typeof deps.notifyFailure === 'function') deps.notifyFailure(entry.sourceConversationId, entry);
|
||||||
|
return stableError('script_start_failed', `启动脚本失败:${error.message}`, { runId, name: resolved.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeRunsHasSourceScript(sourceSessionId, scriptPath) {
|
||||||
|
for (const entry of activeRuns.values()) {
|
||||||
|
if (entry.sourceConversationId === String(sourceSessionId || '').trim() && entry.scriptPath === scriptPath) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRun(args = {}, sourceSessionId = '') {
|
||||||
|
const result = runRecordForSource(sourceSessionId, args.runId);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
const record = result.record;
|
||||||
|
const stream = args.stream === 'stdout' || args.stream === 'stderr' ? args.stream : null;
|
||||||
|
const hasRange = Object.prototype.hasOwnProperty.call(args, 'offset') || Object.prototype.hasOwnProperty.call(args, 'limit');
|
||||||
|
const readOne = (which) => hasRange
|
||||||
|
? rangeFile(path.join(result.scriptsDir, '.runs', record.runId, `${which}.log`), args.offset, args.limit)
|
||||||
|
: tailFile(path.join(result.scriptsDir, '.runs', record.runId, `${which}.log`), DEFAULT_LOG_TAIL_BYTES);
|
||||||
|
const stdout = stream === 'stderr' ? null : readOne('stdout');
|
||||||
|
const stderr = stream === 'stdout' ? null : readOne('stderr');
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
...record,
|
||||||
|
pid: record.pid || null,
|
||||||
|
stdout: stdout?.text || '',
|
||||||
|
stderr: stderr?.text || '',
|
||||||
|
stdoutBytes: stdout?.size || 0,
|
||||||
|
stderrBytes: stderr?.size || 0,
|
||||||
|
stdoutOffset: stdout?.offset || 0,
|
||||||
|
stderrOffset: stderr?.offset || 0,
|
||||||
|
stdoutTruncated: !!stdout?.truncated,
|
||||||
|
stderrTruncated: !!stderr?.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScript(args = {}, sourceSessionId = '') {
|
||||||
|
const id = String(args.runId || '').trim();
|
||||||
|
const active = activeRuns.get(id);
|
||||||
|
if (!active) {
|
||||||
|
const result = runRecordForSource(sourceSessionId, id);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
if (result.record.status !== 'running') return { ok: true, runId: id, status: result.record.status, alreadyFinished: true };
|
||||||
|
return stableError('script_process_unavailable', '脚本记录仍显示运行中,但进程已不在当前服务内。', { runId: id });
|
||||||
|
}
|
||||||
|
if (active.sourceConversationId !== String(sourceSessionId || '').trim()) return stableError('script_run_forbidden', '无权停止该脚本运行。', { runId: id });
|
||||||
|
if (active.finishedAt) return { ok: true, runId: id, status: active.status, alreadyFinished: true };
|
||||||
|
if (active.stopRequested) return { ok: true, runId: id, status: 'stopping', alreadyStopping: true };
|
||||||
|
active.stopRequested = true;
|
||||||
|
active.terminationReason = 'stopped_by_request';
|
||||||
|
persist(active);
|
||||||
|
try { active.process.kill('SIGTERM'); } catch {}
|
||||||
|
active.forceKillTimer = setTimeout(() => {
|
||||||
|
if (!active.finishedAt) {
|
||||||
|
try { active.process.kill('SIGKILL'); } catch {}
|
||||||
|
active.terminationReason = 'forced_after_grace_period';
|
||||||
|
persist(active);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return { ok: true, runId: id, status: 'stopping' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeScriptCall({ runId, sourceSessionId, token } = {}) {
|
||||||
|
const id = String(runId || '').trim();
|
||||||
|
const entry = activeRuns.get(id);
|
||||||
|
if (!entry || entry.finishedAt || entry.tokenRevoked || entry.token !== String(token || '').trim()) {
|
||||||
|
const record = runRecordForSource(sourceSessionId, id);
|
||||||
|
if (record.ok && record.record.status === 'killed') {
|
||||||
|
return stableError('script_stopped', '脚本已停止,运行凭据已吊销。', { runId: id });
|
||||||
|
}
|
||||||
|
return stableError('script_expired', '脚本运行凭据已失效。', { runId: id });
|
||||||
|
}
|
||||||
|
if (entry.sourceConversationId !== String(sourceSessionId || '').trim()) return stableError('script_run_forbidden', '脚本来源对话不匹配。', { runId: entry.runId });
|
||||||
|
return { ok: true, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiManifest() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
packageName: PACKAGE_NAME,
|
||||||
|
version: PACKAGE_VERSION,
|
||||||
|
moduleFormat: 'ESM',
|
||||||
|
importExample: `import { getCurrentConversationId, createConversation, sendMessage, selectSemanticBranch, getLastMessage } from '${PACKAGE_NAME}';`,
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'getCurrentConversationId',
|
||||||
|
parameters: [],
|
||||||
|
returns: 'Promise<string>',
|
||||||
|
description: '返回当前脚本来源对话的 ID,不发起新的会话执行。',
|
||||||
|
errors: ['script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'createConversation',
|
||||||
|
parameters: [{ name: 'prompt', type: 'string' }],
|
||||||
|
returns: 'Promise<string>',
|
||||||
|
description: '创建持久对话并投递首条提示词;等待首轮处理结束后,仅返回新对话 ID。',
|
||||||
|
errors: ['empty_prompt', 'conversation_create_failed', 'conversation_not_found', 'conversation_execution_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sendMessage',
|
||||||
|
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'prompt', type: 'string' }],
|
||||||
|
returns: 'Promise<string>',
|
||||||
|
description: '向指定对话插入提示词,等待该轮处理结束后返回最后一条助手文本;允许目标为当前来源对话。',
|
||||||
|
errors: ['conversation_not_found', 'empty_prompt', 'send_message_failed', 'conversation_execution_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'selectSemanticBranch',
|
||||||
|
parameters: [{ name: 'conversationId', type: 'string' }, { name: 'semantics', type: 'string[]' }],
|
||||||
|
returns: 'Promise<string>',
|
||||||
|
description: '仅依据指定对话最后一条助手消息,由独立只读判断器选择语义数组中的一项;非候选结果最多重试三次。',
|
||||||
|
errors: ['conversation_not_found', 'semantic_branch_invalid', 'last_message_not_found', 'semantic_judge_unavailable', 'semantic_judge_invalid_output', 'semantic_judge_failed', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'getLastMessage',
|
||||||
|
parameters: [{ name: 'conversationId', type: 'string' }],
|
||||||
|
returns: 'Promise<string>',
|
||||||
|
description: '返回指定对话最后一条已完成助手消息的纯文本;不会返回消息对象或中间状态。',
|
||||||
|
errors: ['conversation_not_found', 'last_message_not_found', 'script_context_missing', 'script_mcp_bad_url', 'script_mcp_bad_response', 'script_mcp_request_failed'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limitations: [
|
||||||
|
'5 个函数均返回 Promise,脚本可直接使用 async/await。',
|
||||||
|
'函数调用依赖当前脚本运行上下文;上下文失效或 MCP 请求失败时会以 Error reject。',
|
||||||
|
],
|
||||||
|
errors: {
|
||||||
|
shape: 'Error',
|
||||||
|
fields: ['code', 'message', 'details'],
|
||||||
|
commonCodes: ['conversation_not_found', 'conversation_execution_failed', 'last_message_not_found', 'semantic_branch_invalid', 'semantic_judge_failed', 'script_expired', 'script_stopped'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recover() {
|
||||||
|
try {
|
||||||
|
for (const file of fs.readdirSync(sessionsDir).filter((item) => item.endsWith('.json'))) {
|
||||||
|
let session;
|
||||||
|
try { session = deps.loadSession(path.basename(file, '.json')); } catch { session = null; }
|
||||||
|
if (!session?.cwd) continue;
|
||||||
|
const runsRoot = path.join(path.resolve(session.cwd), '.ccweb', 'scripts', '.runs');
|
||||||
|
if (!fs.existsSync(runsRoot)) continue;
|
||||||
|
for (const runId of fs.readdirSync(runsRoot)) {
|
||||||
|
const metadata = path.join(runsRoot, runId, 'run.json');
|
||||||
|
if (!fs.existsSync(metadata)) continue;
|
||||||
|
try {
|
||||||
|
const record = JSON.parse(fs.readFileSync(metadata, 'utf8'));
|
||||||
|
if (record.status !== 'running' && record.finishedAt) {
|
||||||
|
const finishedAtMs = new Date(record.finishedAt).getTime();
|
||||||
|
if (Number.isFinite(finishedAtMs) && Date.now() - finishedAtMs > runRetentionMs) {
|
||||||
|
fs.rmSync(path.join(runsRoot, runId), { recursive: true, force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (record.status !== 'running') continue;
|
||||||
|
const recoveredPid = Number.parseInt(String(record.pid || ''), 10);
|
||||||
|
let matchesScript = false;
|
||||||
|
if (Number.isInteger(recoveredPid) && recoveredPid > 0 && record.scriptPath) {
|
||||||
|
try {
|
||||||
|
const cmdline = fs.readFileSync(`/proc/${recoveredPid}/cmdline`, 'utf8').replace(/\0/g, ' ');
|
||||||
|
matchesScript = cmdline.includes(String(record.scriptPath)) || cmdline.includes(String(record.name || ''));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (matchesScript) {
|
||||||
|
try { process.kill(recoveredPid, 'SIGTERM'); } catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
try { process.kill(recoveredPid, 'SIGKILL'); } catch {}
|
||||||
|
}, 2000).unref?.();
|
||||||
|
}
|
||||||
|
record.status = 'killed';
|
||||||
|
record.finishedAt = new Date().toISOString();
|
||||||
|
record.durationMs = Math.max(0, new Date(record.finishedAt).getTime() - new Date(record.startedAt).getTime());
|
||||||
|
record.terminationReason = 'server_restarted';
|
||||||
|
record.tokenRevoked = true;
|
||||||
|
const temp = `${metadata}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
|
||||||
|
fs.renameSync(temp, metadata);
|
||||||
|
if (typeof deps.notifyFailure === 'function') deps.notifyFailure(record.sourceConversationId || session.id, record);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createScript,
|
||||||
|
writeScript,
|
||||||
|
runScript,
|
||||||
|
getRun,
|
||||||
|
stopScript,
|
||||||
|
authorizeScriptCall,
|
||||||
|
getApiManifest,
|
||||||
|
recover,
|
||||||
|
handleSessionCall(tool, args, sourceSessionId) {
|
||||||
|
const method = {
|
||||||
|
ccweb_script_get_current_conversation_id: 'getCurrentConversationId',
|
||||||
|
ccweb_script_create_conversation: 'createConversation',
|
||||||
|
ccweb_script_send_message: 'sendMessage',
|
||||||
|
ccweb_script_select_semantic_branch: 'selectSemanticBranch',
|
||||||
|
ccweb_script_get_last_message: 'getLastMessage',
|
||||||
|
}[tool];
|
||||||
|
if (!method || typeof deps.sessionApi?.[method] !== 'function') return stableError('unknown_script_api', `未知脚本会话能力:${tool}`);
|
||||||
|
return deps.sessionApi[method](args || {}, sourceSessionId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PACKAGE_NAME,
|
||||||
|
PACKAGE_VERSION,
|
||||||
|
SCRIPT_TOOL_NAMES,
|
||||||
|
SCRIPT_SESSION_TOOL_NAMES,
|
||||||
|
SCRIPT_TOOL_DEFINITIONS,
|
||||||
|
createJavascriptSessionRuntime,
|
||||||
|
packageIndexSource,
|
||||||
|
packageJsonSource,
|
||||||
|
};
|
||||||
162
scripts/javascript-session-runtime-unit.js
Normal file
162
scripts/javascript-session-runtime-unit.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const {
|
||||||
|
createJavascriptSessionRuntime,
|
||||||
|
packageIndexSource,
|
||||||
|
packageJsonSource,
|
||||||
|
} = require('../lib/javascript-session-runtime');
|
||||||
|
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function waitForRun(runtime, sourceId, runId, predicate, timeoutMs = 10000) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let latest = null;
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
const result = runtime.getRun({ runId }, sourceId);
|
||||||
|
latest = result;
|
||||||
|
if (result.ok && predicate(result)) return result;
|
||||||
|
await delay(50);
|
||||||
|
}
|
||||||
|
throw new Error(`等待脚本运行状态超时:${runId} ${JSON.stringify(latest)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-javascript-session-unit-'));
|
||||||
|
const cwd = path.join(root, 'workspace');
|
||||||
|
const sessionsDir = path.join(root, 'sessions');
|
||||||
|
fs.mkdirSync(cwd, { recursive: true });
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
|
const sourceId = '11111111-1111-4111-8111-111111111111';
|
||||||
|
const sessions = new Map([[sourceId, {
|
||||||
|
id: sourceId,
|
||||||
|
cwd,
|
||||||
|
messages: [{ role: 'assistant', content: '最后消息' }],
|
||||||
|
}]]);
|
||||||
|
fs.writeFileSync(path.join(sessionsDir, `${sourceId}.json`), JSON.stringify(sessions.get(sourceId)));
|
||||||
|
const notifications = [];
|
||||||
|
|
||||||
|
const mcpServer = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (chunk) => { body += chunk; });
|
||||||
|
req.on('end', () => {
|
||||||
|
const payload = JSON.parse(body || '{}');
|
||||||
|
assert.match(String(req.headers['x-cc-web-mcp-token'] || ''), /^[0-9a-f]{64}$/);
|
||||||
|
let response;
|
||||||
|
switch (payload.tool) {
|
||||||
|
case 'ccweb_script_get_current_conversation_id':
|
||||||
|
response = { ok: true, conversationId: payload.sourceSessionId };
|
||||||
|
break;
|
||||||
|
case 'ccweb_script_get_last_message':
|
||||||
|
response = { ok: true, conversationId: payload.args.conversationId, message: '服务端最后消息' };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
response = { ok: false, code: 'unexpected_test_tool', message: payload.tool };
|
||||||
|
}
|
||||||
|
res.writeHead(response.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(response));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => mcpServer.listen(0, '127.0.0.1', resolve));
|
||||||
|
const mcpUrl = `http://127.0.0.1:${mcpServer.address().port}/api/internal/mcp`;
|
||||||
|
|
||||||
|
const runtime = createJavascriptSessionRuntime({
|
||||||
|
sessionsDir,
|
||||||
|
internalMcpUrl: mcpUrl,
|
||||||
|
loadSession: (id) => sessions.get(id) || null,
|
||||||
|
notifyFailure: (id, entry) => notifications.push({ id, entry: { ...entry } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(runtime.getApiManifest().ok, true);
|
||||||
|
assert.equal(runtime.getApiManifest().packageName, '@ccweb/session');
|
||||||
|
const manifest = runtime.getApiManifest();
|
||||||
|
assert.equal(manifest.functions.length, 5);
|
||||||
|
assert.equal(manifest.functions.every((item) => item.description && Array.isArray(item.errors)), true);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptTools'), false);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'scriptToolExamples'), false);
|
||||||
|
assert.match(packageJsonSource(), /"type": "module"/);
|
||||||
|
assert.match(packageIndexSource(), /export async function sendMessage/);
|
||||||
|
|
||||||
|
assert.equal(runtime.createScript({ name: '../escape.js' }, sourceId).code, 'invalid_script_name');
|
||||||
|
assert.equal(runtime.createScript({ name: 'workflow.txt' }, sourceId).code, 'invalid_script_name');
|
||||||
|
assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).ok, true);
|
||||||
|
assert.equal(runtime.createScript({ name: 'workflow.js' }, sourceId).code, 'script_exists');
|
||||||
|
|
||||||
|
const workflow = [
|
||||||
|
"import { getCurrentConversationId, getLastMessage } from '@ccweb/session';",
|
||||||
|
'await new Promise((resolve) => setTimeout(resolve, 400));',
|
||||||
|
'console.log(JSON.stringify({ current: await getCurrentConversationId(), last: await getLastMessage(await getCurrentConversationId()) }));',
|
||||||
|
].join('\n');
|
||||||
|
assert.equal(runtime.writeScript({ name: 'workflow.js', content: workflow }, sourceId).ok, true);
|
||||||
|
assert.equal(runtime.writeScript({ name: 'too-large.js', content: 'x'.repeat(1024 * 1024 + 1) }, sourceId).code, 'script_content_too_large');
|
||||||
|
|
||||||
|
const started = runtime.runScript({ name: 'workflow.js' }, sourceId);
|
||||||
|
assert.equal(started.ok, true);
|
||||||
|
const duplicate = runtime.runScript({ name: 'workflow.js' }, sourceId);
|
||||||
|
assert.equal(duplicate.code, 'script_already_running');
|
||||||
|
const succeeded = await waitForRun(runtime, sourceId, started.runId, (run) => run.status === 'succeeded');
|
||||||
|
assert.equal(succeeded.exitCode, 0);
|
||||||
|
assert.match(succeeded.stdout, new RegExp(sourceId));
|
||||||
|
assert.match(succeeded.stdout, /服务端最后消息/);
|
||||||
|
assert.equal(succeeded.stderr, '');
|
||||||
|
assert.equal(notifications.length, 0);
|
||||||
|
|
||||||
|
assert.equal(runtime.writeScript({ name: 'failed.js', content: "console.error('expected failure'); process.exit(3);" }, sourceId).ok, true);
|
||||||
|
const failedStart = runtime.runScript({ name: 'failed.js' }, sourceId);
|
||||||
|
const failed = await waitForRun(runtime, sourceId, failedStart.runId, (run) => run.status === 'failed');
|
||||||
|
assert.equal(failed.exitCode, 3);
|
||||||
|
assert.match(failed.stderr, /expected failure/);
|
||||||
|
assert.equal(notifications.length, 1);
|
||||||
|
assert.equal(notifications[0].entry.runId, failedStart.runId);
|
||||||
|
|
||||||
|
assert.equal(runtime.writeScript({ name: 'long.js', content: "setInterval(() => console.log('running'), 50);" }, sourceId).ok, true);
|
||||||
|
const longStart = runtime.runScript({ name: 'long.js' }, sourceId);
|
||||||
|
assert.equal(runtime.stopScript({ runId: longStart.runId }, sourceId).status, 'stopping');
|
||||||
|
const killed = await waitForRun(runtime, sourceId, longStart.runId, (run) => run.status === 'killed');
|
||||||
|
assert.equal(killed.terminationReason, 'stopped_by_request');
|
||||||
|
assert.equal(notifications.length, 1, '主动停止不应触发异常通知');
|
||||||
|
|
||||||
|
const symlinkPath = path.join(cwd, 'outside.js');
|
||||||
|
fs.writeFileSync(symlinkPath, 'console.log(1);');
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(symlinkPath, path.join(cwd, '.ccweb', 'scripts', 'link.js'));
|
||||||
|
assert.equal(runtime.runScript({ name: 'link.js' }, sourceId).code, 'script_symlink_forbidden');
|
||||||
|
} catch (error) {
|
||||||
|
if (!['EPERM', 'EACCES'].includes(error?.code)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveredRunId = '22222222-2222-4222-8222-222222222222';
|
||||||
|
const recoveredDir = path.join(cwd, '.ccweb', 'scripts', '.runs', recoveredRunId);
|
||||||
|
fs.mkdirSync(recoveredDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(recoveredDir, 'run.json'), JSON.stringify({
|
||||||
|
runId: recoveredRunId,
|
||||||
|
sourceConversationId: sourceId,
|
||||||
|
name: 'old.js',
|
||||||
|
status: 'running',
|
||||||
|
startedAt: new Date(Date.now() - 1000).toISOString(),
|
||||||
|
tokenRevoked: false,
|
||||||
|
}));
|
||||||
|
runtime.recover();
|
||||||
|
const recovered = JSON.parse(fs.readFileSync(path.join(recoveredDir, 'run.json'), 'utf8'));
|
||||||
|
assert.equal(recovered.status, 'killed');
|
||||||
|
assert.equal(recovered.terminationReason, 'server_restarted');
|
||||||
|
assert.equal(notifications.some((item) => item.entry.runId === recoveredRunId), true);
|
||||||
|
|
||||||
|
console.log('javascript-session-runtime-unit: ok');
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => mcpServer.close(resolve));
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.stack || error.message || error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
341
server.js
341
server.js
@@ -31,6 +31,10 @@ const {
|
|||||||
isCallableToolName,
|
isCallableToolName,
|
||||||
prepareImagePayload,
|
prepareImagePayload,
|
||||||
} = require('./lib/ccweb-mcp-server');
|
} = require('./lib/ccweb-mcp-server');
|
||||||
|
const {
|
||||||
|
SCRIPT_SESSION_TOOL_NAMES,
|
||||||
|
createJavascriptSessionRuntime,
|
||||||
|
} = require('./lib/javascript-session-runtime');
|
||||||
const {
|
const {
|
||||||
TaskBoardError,
|
TaskBoardError,
|
||||||
createTaskBoardService,
|
createTaskBoardService,
|
||||||
@@ -1460,6 +1464,7 @@ const pendingCodexAppUserInputs = new Map();
|
|||||||
const pendingCodexAppApprovals = new Map();
|
const pendingCodexAppApprovals = new Map();
|
||||||
// Pending MCP elicitation requests: requestId -> { sessionId, params, resolve, timer }
|
// Pending MCP elicitation requests: requestId -> { sessionId, params, resolve, timer }
|
||||||
const pendingCodexAppElicitations = new Map();
|
const pendingCodexAppElicitations = new Map();
|
||||||
|
let javascriptSessionRuntime = null;
|
||||||
let taskBoardService = null;
|
let taskBoardService = null;
|
||||||
let taskBoardMcpHandlers = null;
|
let taskBoardMcpHandlers = null;
|
||||||
let taskBoardLifecycle = null;
|
let taskBoardLifecycle = null;
|
||||||
@@ -7430,7 +7435,169 @@ function completeCrossConversationReply(requestId, entry = {}, targetSession = n
|
|||||||
return deliverCrossConversationReply(normalizedRequestId);
|
return deliverCrossConversationReply(normalizedRequestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
function latestAssistantMessageText(session) {
|
||||||
|
const messages = Array.isArray(session?.messages) ? session.messages : [];
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (message?.role !== 'assistant') continue;
|
||||||
|
const text = extractCrossConversationReplyText(message.content || '');
|
||||||
|
if (text.trim()) return text.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantMessageCount(session) {
|
||||||
|
return Array.isArray(session?.messages)
|
||||||
|
? session.messages.filter((message) => message?.role === 'assistant').length
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForJavascriptSessionTurn(sessionId, baselineAssistantCount = 0) {
|
||||||
|
const normalizedId = sanitizeId(sessionId || '');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
const session = loadSession(normalizedId);
|
||||||
|
if (!session) {
|
||||||
|
reject(Object.assign(new Error('目标对话不存在。'), {
|
||||||
|
code: 'conversation_not_found',
|
||||||
|
details: { conversationId: normalizedId },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSessionRunning(normalizedId)) {
|
||||||
|
setTimeout(check, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentAssistantCount = assistantMessageCount(session);
|
||||||
|
const changed = currentAssistantCount > baselineAssistantCount
|
||||||
|
|| (baselineAssistantCount === 0 && currentAssistantCount > 0);
|
||||||
|
const message = latestAssistantMessageText(session);
|
||||||
|
if (!changed || !message) {
|
||||||
|
reject(Object.assign(new Error('目标对话本轮未返回可用助手消息。'), {
|
||||||
|
code: 'conversation_execution_failed',
|
||||||
|
details: { conversationId: normalizedId, targetStatus: 'idle' },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({ session, message });
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptApiError(code, message, details = {}) {
|
||||||
|
return Promise.reject(Object.assign(new Error(message), { code, details }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptCreateConversationApi(args = {}, sourceConversationId = '') {
|
||||||
|
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
||||||
|
if (!prompt) return scriptApiError('empty_prompt', '创建对话提示词不能为空。');
|
||||||
|
const created = createMcpConversation({ initialMessage: prompt }, sourceConversationId, 0);
|
||||||
|
if (!created?.ok) return scriptApiError(created.code || 'conversation_create_failed', created.message || '创建对话失败。', created);
|
||||||
|
const session = loadSession(created.conversationId);
|
||||||
|
const baseline = assistantMessageCount(session);
|
||||||
|
return waitForJavascriptSessionTurn(created.conversationId, baseline).then(() => ({
|
||||||
|
ok: true,
|
||||||
|
conversationId: created.conversationId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptSendMessageApi(args = {}, sourceConversationId = '') {
|
||||||
|
const conversationId = sanitizeId(args.conversationId || '');
|
||||||
|
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
||||||
|
if (!conversationId) return scriptApiError('conversation_not_found', 'conversationId 无效。');
|
||||||
|
if (!prompt) return scriptApiError('empty_prompt', '消息提示词不能为空。', { conversationId });
|
||||||
|
const target = loadSession(conversationId);
|
||||||
|
if (!target) return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||||
|
const baseline = assistantMessageCount(target);
|
||||||
|
const targetWs = findViewingSessionWs(conversationId);
|
||||||
|
const result = handleMessage(targetWs, {
|
||||||
|
text: prompt,
|
||||||
|
sessionId: conversationId,
|
||||||
|
mode: target.permissionMode || 'yolo',
|
||||||
|
agent: getSessionAgent(target),
|
||||||
|
}, {
|
||||||
|
runtimeText: prompt,
|
||||||
|
displayText: prompt,
|
||||||
|
emitUserMessage: true,
|
||||||
|
skipPendingCrossConversationFlush: true,
|
||||||
|
});
|
||||||
|
if (!result?.ok) return scriptApiError(result.code || 'send_message_failed', result.message || '发送消息失败。', { conversationId });
|
||||||
|
return waitForJavascriptSessionTurn(conversationId, baseline).then(({ message }) => ({
|
||||||
|
ok: true,
|
||||||
|
conversationId,
|
||||||
|
message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptGetLastMessageApi(args = {}) {
|
||||||
|
const conversationId = sanitizeId(args.conversationId || '');
|
||||||
|
if (!conversationId) return scriptApiError('conversation_not_found', 'conversationId 无效。');
|
||||||
|
const session = loadSession(conversationId);
|
||||||
|
if (!session) return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||||
|
const message = latestAssistantMessageText(session);
|
||||||
|
if (!message) return scriptApiError('last_message_not_found', '目标对话没有可用的助手消息。', { conversationId });
|
||||||
|
return Promise.resolve({ ok: true, conversationId, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSemanticCandidates(rawSemantics) {
|
||||||
|
if (!Array.isArray(rawSemantics)) return { ok: false, error: scriptApiError('semantic_branch_invalid', '语义数组必须是数组。') };
|
||||||
|
const values = rawSemantics.map((item) => (typeof item === 'string' ? item : null));
|
||||||
|
if (values.some((item) => item === null)) return { ok: false, error: scriptApiError('semantic_branch_invalid', '语义数组每项必须是字符串。') };
|
||||||
|
const normalized = values.map((item) => item.trim());
|
||||||
|
if (normalized.some((item) => !item || Array.from(item).length > 200)) return { ok: false, error: scriptApiError('semantic_branch_invalid', '语义数组每项必须是非空且不超过 200 个 Unicode 字符的字符串。') };
|
||||||
|
if (new Set(normalized).size < 2) return { ok: false, error: scriptApiError('semantic_branch_invalid', '语义数组至少需要两个唯一候选项。') };
|
||||||
|
return { ok: true, original: values, normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scriptSelectSemanticBranchApi(args = {}, sourceConversationId = '') {
|
||||||
|
const conversationId = sanitizeId(args.conversationId || '');
|
||||||
|
const candidates = validateSemanticCandidates(args.semantics);
|
||||||
|
if (!conversationId) return scriptApiError('conversation_not_found', 'conversationId 无效。');
|
||||||
|
if (!candidates.ok) return candidates.error;
|
||||||
|
const session = loadSession(conversationId);
|
||||||
|
if (!session) return scriptApiError('conversation_not_found', '目标对话不存在。', { conversationId });
|
||||||
|
const lastMessage = latestAssistantMessageText(session);
|
||||||
|
if (!lastMessage) return scriptApiError('last_message_not_found', '目标对话没有可供判断的最后助手消息。', { conversationId });
|
||||||
|
if (typeof judgeJavascriptSemanticBranch !== 'function') {
|
||||||
|
return scriptApiError('semantic_judge_unavailable', '独立语义判断器当前不可用。', { conversationId });
|
||||||
|
}
|
||||||
|
return judgeJavascriptSemanticBranch(lastMessage, candidates.normalized, session.cwd || getDefaultSessionCwd())
|
||||||
|
.then((branch) => {
|
||||||
|
const index = candidates.normalized.findIndex((candidate) => candidate === branch);
|
||||||
|
return { ok: true, conversationId, branch: index >= 0 ? candidates.original[index] : branch };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertJavascriptRunFailureNotice(sourceConversationId, entry = {}) {
|
||||||
|
const session = loadSession(sourceConversationId);
|
||||||
|
if (!session) return false;
|
||||||
|
if (Array.isArray(session.messages) && session.messages.some((message) => message?.scriptNotification?.runId === entry.runId)) return false;
|
||||||
|
const reason = entry.terminationReason || entry.signal || `exitCode=${entry.exitCode ?? 'unknown'}`;
|
||||||
|
const stderr = String(entry.stderrPreview || '').trim();
|
||||||
|
const lines = [
|
||||||
|
`${entry.name || 'JavaScript 脚本'} 脚本意外结束。`,
|
||||||
|
`runId:${entry.runId || 'unknown'}`,
|
||||||
|
`状态:${entry.status || 'failed'}`,
|
||||||
|
`原因:${reason}`,
|
||||||
|
];
|
||||||
|
if (stderr) lines.push('', `stderr 尾部:\n${stderr}`);
|
||||||
|
const message = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: lines.join('\n'),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
scriptNotification: { runId: entry.runId || null, status: entry.status || 'failed', unexpected: true },
|
||||||
|
};
|
||||||
|
session.messages = Array.isArray(session.messages) ? session.messages : [];
|
||||||
|
session.messages.push(message);
|
||||||
|
session.updated = new Date().toISOString();
|
||||||
|
session.hasUnread = !findViewingSessionWs(session.id);
|
||||||
|
saveSession(session);
|
||||||
|
sendSessionEventToViewers(session.id, { type: 'session_message', sessionId: session.id, message });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount, scriptContext = {}) {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case 'ccweb_internal_tools_list':
|
case 'ccweb_internal_tools_list':
|
||||||
return {
|
return {
|
||||||
@@ -7458,6 +7625,32 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
|||||||
return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
|
return requestCrossConversationReply(args, sourceSessionId, sourceHopCount);
|
||||||
case 'ccweb_prompt_user':
|
case 'ccweb_prompt_user':
|
||||||
return createCcwebPromptUser(args, sourceSessionId);
|
return createCcwebPromptUser(args, sourceSessionId);
|
||||||
|
case 'ccweb_create_javascript_script':
|
||||||
|
return javascriptSessionRuntime?.createScript(args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_write_javascript_script':
|
||||||
|
return javascriptSessionRuntime?.writeScript(args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_run_javascript_script':
|
||||||
|
return javascriptSessionRuntime?.runScript(args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_get_javascript_script_run':
|
||||||
|
return javascriptSessionRuntime?.getRun(args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_stop_javascript_script':
|
||||||
|
return javascriptSessionRuntime?.stopScript(args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_javascript_session_api':
|
||||||
|
return javascriptSessionRuntime?.getApiManifest()
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
|
case 'ccweb_script_get_current_conversation_id':
|
||||||
|
case 'ccweb_script_create_conversation':
|
||||||
|
case 'ccweb_script_send_message':
|
||||||
|
case 'ccweb_script_select_semantic_branch':
|
||||||
|
case 'ccweb_script_get_last_message':
|
||||||
|
if (!scriptContext.authorized) return mcpToolError('script_authorization_required', '该能力只能由已授权的 JavaScript 脚本调用。');
|
||||||
|
return javascriptSessionRuntime?.handleSessionCall(tool, args, sourceSessionId)
|
||||||
|
|| mcpToolError('javascript_runtime_unavailable', 'JavaScript 脚本运行时尚未就绪。');
|
||||||
case 'ccweb_task_update': {
|
case 'ccweb_task_update': {
|
||||||
if (!TASK_BOARD_ENABLED) {
|
if (!TASK_BOARD_ENABLED) {
|
||||||
return mcpToolError('task_board_disabled', '任务看板正在重构,暂不可用。');
|
return mcpToolError('task_board_disabled', '任务看板正在重构,暂不可用。');
|
||||||
@@ -7587,9 +7780,7 @@ async function handleSharedMcpHttpApi(req, res, url) {
|
|||||||
|
|
||||||
async function handleInternalMcpApi(req, res) {
|
async function handleInternalMcpApi(req, res) {
|
||||||
const token = getInternalMcpRequestToken(req);
|
const token = getInternalMcpRequestToken(req);
|
||||||
if (!token || token !== INTERNAL_MCP_TOKEN) {
|
if (!token) return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
|
||||||
return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
@@ -7602,10 +7793,149 @@ async function handleInternalMcpApi(req, res) {
|
|||||||
const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
|
const args = payload.args && typeof payload.args === 'object' ? payload.args : {};
|
||||||
const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
|
const sourceSessionId = sanitizeId(payload.sourceSessionId || '');
|
||||||
const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
|
const sourceHopCount = Number.parseInt(String(payload.sourceHopCount || 0), 10) || 0;
|
||||||
const result = callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount);
|
const scriptRunId = String(payload.scriptRunId || '').trim();
|
||||||
|
let scriptContext = {};
|
||||||
|
if (scriptRunId) {
|
||||||
|
const authorized = javascriptSessionRuntime?.authorizeScriptCall({
|
||||||
|
runId: scriptRunId,
|
||||||
|
sourceSessionId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
if (!authorized?.ok) return jsonResponse(res, 401, authorized || mcpToolError('script_expired', '脚本运行凭据已失效。'));
|
||||||
|
if (!SCRIPT_SESSION_TOOL_NAMES.includes(tool)) {
|
||||||
|
return jsonResponse(res, 403, mcpToolError('script_tool_forbidden', '脚本凭据只允许调用标准会话函数。', { tool }));
|
||||||
|
}
|
||||||
|
scriptContext = { authorized: true, scriptRunId };
|
||||||
|
} else if (token !== INTERNAL_MCP_TOKEN) {
|
||||||
|
return jsonResponse(res, 401, mcpToolError('unauthorized', 'MCP 内部接口未授权。'));
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await Promise.resolve(callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount, scriptContext));
|
||||||
|
} catch (error) {
|
||||||
|
const details = error?.details && typeof error.details === 'object' ? error.details : {};
|
||||||
|
result = mcpToolError(error?.code || 'script_call_failed', error?.message || '脚本会话调用失败。', details);
|
||||||
|
}
|
||||||
return jsonResponse(res, result.ok ? 200 : 400, result);
|
return jsonResponse(res, result.ok ? 200 : 400, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function judgeJavascriptSemanticBranch(lastMessage, candidates, cwd) {
|
||||||
|
const normalizedCandidates = Array.isArray(candidates) ? candidates : [];
|
||||||
|
const strictPrompt = (attempt) => [
|
||||||
|
'你是一个严格的语义分支选择器。',
|
||||||
|
'只阅读“最后一条助手消息”,并从“候选语义数组”中选择最符合的一项。',
|
||||||
|
'你必须只输出候选数组中的一项原始文本,不得输出解释、前后缀、引号、Markdown、JSON 或其他字符。',
|
||||||
|
`这是第 ${attempt} 次尝试。若无法判断,也必须选择最符合的一项。`,
|
||||||
|
'',
|
||||||
|
'候选语义数组:',
|
||||||
|
JSON.stringify(normalizedCandidates),
|
||||||
|
'',
|
||||||
|
'最后一条助手消息:',
|
||||||
|
String(lastMessage || ''),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
let client = null;
|
||||||
|
let entry = {
|
||||||
|
fullText: '',
|
||||||
|
toolCalls: [],
|
||||||
|
toolOutputDeltas: new Map(),
|
||||||
|
agentMessageItems: new Map(),
|
||||||
|
lastError: null,
|
||||||
|
errorSent: false,
|
||||||
|
ws: null,
|
||||||
|
mcpContext: {},
|
||||||
|
};
|
||||||
|
let doneResolve;
|
||||||
|
let doneReject;
|
||||||
|
const done = new Promise((resolve, reject) => { doneResolve = resolve; doneReject = reject; });
|
||||||
|
try {
|
||||||
|
const spec = buildCodexAppClientSpec();
|
||||||
|
if (spec?.error) throw Object.assign(new Error(spec.error), { code: 'semantic_judge_unavailable' });
|
||||||
|
client = createCodexAppServerClient({
|
||||||
|
command: spec.command,
|
||||||
|
args: spec.args,
|
||||||
|
env: spec.env,
|
||||||
|
cwd: cwd || process.cwd(),
|
||||||
|
clientInfo: { name: 'ccweb_semantic_judge', title: 'CC-Web Semantic Judge', version: '1.0.0' },
|
||||||
|
postInitialize: codexAppPostInitialize,
|
||||||
|
onNotification(notification) {
|
||||||
|
const result = codexAppRuntime.processCodexAppNotification(entry, notification, 'javascript-semantic-judge');
|
||||||
|
if (result?.done) doneResolve();
|
||||||
|
},
|
||||||
|
onExit(info) {
|
||||||
|
if (!entry.fullText && info?.stderr) entry.lastError = info.stderr;
|
||||||
|
doneReject(Object.assign(new Error(info?.stderr || '语义判断器进程退出。'), { code: 'semantic_judge_process_exit' }));
|
||||||
|
},
|
||||||
|
onLog(level, event, data) {
|
||||||
|
if (level === 'WARN') plog('WARN', `javascript_semantic_judge_${event}`, data || {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.start();
|
||||||
|
const modelSettings = codexAppModelSettings({ model: getDefaultCodexModel() });
|
||||||
|
const started = await client.request('thread/start', {
|
||||||
|
cwd: cwd || process.cwd(),
|
||||||
|
approvalPolicy: 'never',
|
||||||
|
sandbox: 'read-only',
|
||||||
|
model: modelSettings.model,
|
||||||
|
threadSource: 'user',
|
||||||
|
}, 60000);
|
||||||
|
const threadId = started?.thread?.id;
|
||||||
|
if (!threadId) throw Object.assign(new Error('判断器未返回 threadId。'), { code: 'semantic_judge_start_failed' });
|
||||||
|
await client.request('turn/start', {
|
||||||
|
threadId,
|
||||||
|
input: [{ type: 'text', text: strictPrompt(attempt) }],
|
||||||
|
cwd: cwd || process.cwd(),
|
||||||
|
approvalPolicy: 'never',
|
||||||
|
sandboxPolicy: { type: 'readOnly', networkAccess: false },
|
||||||
|
collaborationMode: {
|
||||||
|
mode: 'default',
|
||||||
|
settings: {
|
||||||
|
model: modelSettings.model || FALLBACK_CODEX_MODEL,
|
||||||
|
reasoning_effort: modelSettings.effort || null,
|
||||||
|
developer_instructions: '只输出候选数组中的一个原始字符串。不要调用工具,不要修改文件。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, 60000);
|
||||||
|
await done;
|
||||||
|
const raw = String(entry.fullText || '').trim();
|
||||||
|
const matched = normalizedCandidates.find((candidate) => raw === candidate || raw === String(candidate).trim());
|
||||||
|
if (matched !== undefined) return matched;
|
||||||
|
lastError = Object.assign(new Error('判断器返回了非候选项。'), {
|
||||||
|
code: 'semantic_judge_invalid_output',
|
||||||
|
details: { attempt, output: raw.slice(0, 500), candidates: normalizedCandidates },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
} finally {
|
||||||
|
if (client) client.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('语义判断器连续三次未返回合法候选项。'), {
|
||||||
|
code: lastError?.code || 'semantic_judge_failed',
|
||||||
|
details: { attempts: 3, lastError: lastError?.message || String(lastError || '') },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeJavascriptSessionRuntime() {
|
||||||
|
javascriptSessionRuntime = createJavascriptSessionRuntime({
|
||||||
|
sessionsDir: SESSIONS_DIR,
|
||||||
|
internalMcpUrl: `http://127.0.0.1:${PORT}/api/internal/mcp`,
|
||||||
|
loadSession,
|
||||||
|
saveSession,
|
||||||
|
notifyFailure: insertJavascriptRunFailureNotice,
|
||||||
|
sessionApi: {
|
||||||
|
getCurrentConversationId: async (args, sourceConversationId) => ({ ok: true, conversationId: sourceConversationId || null }),
|
||||||
|
createConversation: scriptCreateConversationApi,
|
||||||
|
sendMessage: scriptSendMessageApi,
|
||||||
|
selectSemanticBranch: scriptSelectSemanticBranchApi,
|
||||||
|
getLastMessage: scriptGetLastMessageApi,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
javascriptSessionRuntime.recover();
|
||||||
|
}
|
||||||
|
|
||||||
// === File Tailer ===
|
// === File Tailer ===
|
||||||
// Tails a file and calls onLine for each new complete line.
|
// Tails a file and calls onLine for each new complete line.
|
||||||
class FileTailer {
|
class FileTailer {
|
||||||
@@ -14026,6 +14356,7 @@ function handleListCwdSuggestions(ws) {
|
|||||||
|
|
||||||
// === Startup ===
|
// === Startup ===
|
||||||
loadCrossConversationReplies();
|
loadCrossConversationReplies();
|
||||||
|
initializeJavascriptSessionRuntime();
|
||||||
recoverProcesses();
|
recoverProcesses();
|
||||||
// 先恢复 Codex App 运行态,再领取 Gitea 队列,避免重启窗口产生重复 turn。
|
// 先恢复 Codex App 运行态,再领取 Gitea 队列,避免重启窗口产生重复 turn。
|
||||||
giteaWorkflowService.queue.recover();
|
giteaWorkflowService.queue.recover();
|
||||||
|
|||||||
Reference in New Issue
Block a user