Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

### Added

- 手机壳与 phone-link.v1:手机打开产品为原生壳(组织/指令/桌面/设置);一键连接电脑发指令;组织跟随电脑当前工作区;设置可断开配对。托管入口见 deploy/ 与 start.sh。

- #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。

- #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。
Expand Down
249 changes: 249 additions & 0 deletions apps/desktop/src/phone-link-host.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
const { randomUUID } = require("node:crypto");
const path = require("node:path");
const { pathToFileURL } = require("node:url");

const MAX_SUMMARY = 2000;

function loadWebSocket() {
if (typeof WebSocket === "function") return WebSocket;
const wsPath = path.join(__dirname, "..", "..", "..", "deploy", "node_modules", "ws");
return require(wsPath).WebSocket;
}

function bind(socket, event, handler) {
const wrapped = (...args) => {
if (event === "message") {
const raw = args[0] && args[0].data !== undefined ? args[0].data : args[0];
handler({ data: raw });
return;
}
handler(args[0]);
};
if (typeof socket.addEventListener === "function") socket.addEventListener(event, wrapped);
else socket.on(event, wrapped);
}

function truncate(text) {
if (typeof text !== "string" || text.length === 0) return "";
return text.length <= MAX_SUMMARY ? text : `${text.slice(0, MAX_SUMMARY - 1)}…`;
}

function summarizeTurn(record) {
if (!record || typeof record !== "object") return "已完成";
if (typeof record.output === "string") return truncate(record.output);
if (record.output && typeof record.output === "object") {
for (const key of ["text", "answer", "message", "content"]) {
if (typeof record.output[key] === "string") return truncate(record.output[key]);
}
}
const deltas = Array.isArray(record.events)
? record.events
.filter((event) => event && event.type === "model.delta")
.map((event) => event.text || event.delta || "")
.join("")
: "";
if (deltas) return truncate(deltas);
if (record.error && typeof record.error.message === "string") return truncate(record.error.message);
return "已完成";
}

function firstReadyEngine(health) {
const hosts = health && typeof health === "object" ? health.hosts : null;
if (!hosts || typeof hosts !== "object") return null;
const preferred = ["qoder", "claude-local", "claude-code", "codex-local", "codex", "workbuddy"];
for (const id of preferred) {
if (hosts[id] && hosts[id].ready === true) return id;
}
for (const [id, host] of Object.entries(hosts)) {
if (host && host.ready === true) return id;
}
return null;
}

function firstPositionId(tree, preferred) {
if (typeof preferred === "string" && preferred.trim()) return preferred.trim();
const walk = (nodes) => {
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
if (node && typeof node.id === "string") return node.id;
const nested = walk(node && node.children);
if (nested) return nested;
}
return null;
};
return walk(tree && tree.tree);
}

async function defaultLoadSnapshot(dir) {
const url = pathToFileURL(path.join(__dirname, "..", "..", "..", "deploy", "mobile-surface.mjs")).href;
const mod = await import(url);
return mod.liveOrgSnapshot(mod.loadWorkspaceSnapshot(dir), dir);
}

async function collectLiveOrgSnapshot(apiRequest, loadSnapshot = defaultLoadSnapshot) {
const workspace = await apiRequest("/workspace");
if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) {
return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
}
const dir = workspace.body.path;
if (typeof dir !== "string" || dir.length === 0) {
return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
}
try {
const snapshot = await loadSnapshot(dir);
const live = snapshot && snapshot.source === "live"
? snapshot
: { ...snapshot, open: true, source: "live" };
if (JSON.stringify(live).includes(dir)) {
return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
}
return live;
} catch {
return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] };
}
}

async function dispatchPhoneCommand(apiRequest, text, preferredPositionId) {
const workspace = await apiRequest("/workspace");
if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) {
return { state: "failed", summary: "电脑上还没有打开工作区" };
}
const org = await apiRequest("/org/tree");
const positionId = firstPositionId(org.body, preferredPositionId);
if (!positionId) return { state: "failed", summary: "组织里没有可派发的员工" };
const health = await apiRequest("/health", { withAuth: false });
const engine = firstReadyEngine(health.body);
if (!engine) return { state: "failed", summary: "本机 Agent 还没就绪" };
const sessions = await apiRequest(`/sessions?positionId=${encodeURIComponent(positionId)}`);
let sessionId = sessions.body && sessions.body.activeSessionId;
if (!sessionId) {
const created = await apiRequest("/sessions", { method: "POST", body: { positionId } });
if (created.status !== 201 && created.status !== 200) {
return { state: "failed", summary: created.body && created.body.message ? created.body.message : "无法创建会话" };
}
sessionId = created.body.sessionId;
}
const turn = await apiRequest(`/sessions/${sessionId}/turns`, {
method: "POST",
body: { input: text, engine },
});
if (turn.status === 409) return { state: "busy", summary: "该员工正在处理别的任务" };
if (turn.status === 400 && turn.body && /approval/i.test(String(turn.body.message ?? ""))) {
return { state: "needs_approval", summary: "请在电脑上确认这次操作" };
}
if (turn.status !== 200 && turn.status !== 201 && turn.status !== 202) {
return { state: "failed", summary: turn.body && turn.body.message ? String(turn.body.message) : "回合没有启动" };
}
if (turn.body && turn.body.status === "failed") {
return { state: "failed", summary: summarizeTurn(turn.body) };
}
return { state: "completed", summary: summarizeTurn(turn.body) };
}

function startPhoneLinkHost({
port,
hostToken,
apiRequest,
preferredPositionId,
onPairCode,
WebSocketImpl = loadWebSocket(),
} = {}) {
if (!port || typeof hostToken !== "string" || !/^[a-f0-9]{64}$/.test(hostToken)) {
return { stop() {} };
}
let socket = null;
let stopped = false;
let retryTimer = null;
let orgTimer = null;

const publishOrg = async () => {
if (stopped || !socket) return;
try {
const snapshot = await collectLiveOrgSnapshot(apiRequest);
if (stopped || !socket) return;
socket.send(JSON.stringify({ v: 1, type: "org.snapshot", snapshot }));
} catch {
// A missed org refresh must not drop the command channel.
}
};

const connect = () => {
if (stopped) return;
socket = new WebSocketImpl(`ws://127.0.0.1:${port}/phone-link/host`);
bind(socket, "open", () => {
socket.send(JSON.stringify({ v: 1, type: "host.hello", token: hostToken }));
});
bind(socket, "message", async (event) => {
let message;
try {
message = JSON.parse(String(event.data));
} catch {
return;
}
if (message.type === "host.accepted") {
socket.send(JSON.stringify({ v: 1, type: "pair.start" }));
void publishOrg();
if (orgTimer) clearInterval(orgTimer);
orgTimer = setInterval(() => { void publishOrg(); }, 5000);
return;
}
if (message.type === "pair.ready" && typeof onPairCode === "function") {
onPairCode(message.code, message.expiresAt);
return;
}
if (message.type !== "command.submit") return;
socket.send(JSON.stringify({
v: 1, type: "command.status", commandId: message.commandId,
deviceId: message.deviceId, state: "running",
}));
let result;
try {
result = await dispatchPhoneCommand(
apiRequest,
message.text,
message.positionId || preferredPositionId,
);
} catch (error) {
result = { state: "failed", summary: error instanceof Error ? error.message : "dispatch failed" };
}
if (stopped || !socket) return;
socket.send(JSON.stringify({
v: 1, type: "command.status", commandId: message.commandId,
deviceId: message.deviceId, state: result.state, summary: result.summary,
}));
});
bind(socket, "close", () => {
if (orgTimer) {
clearInterval(orgTimer);
orgTimer = null;
}
if (stopped) return;
retryTimer = setTimeout(connect, 2000);
});
bind(socket, "error", () => {});
};

connect();
return {
stop() {
stopped = true;
if (retryTimer) clearTimeout(retryTimer);
if (orgTimer) clearInterval(orgTimer);
if (socket) socket.close();
},
};
}

function newCommandId() {
return randomUUID();
}

module.exports = {
dispatchPhoneCommand,
collectLiveOrgSnapshot,
summarizeTurn,
firstReadyEngine,
firstPositionId,
startPhoneLinkHost,
newCommandId,
};
89 changes: 89 additions & 0 deletions apps/desktop/test/phone-link-host.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
collectLiveOrgSnapshot,
dispatchPhoneCommand,
firstPositionId,
firstReadyEngine,
summarizeTurn,
} = require("../src/phone-link-host.cjs");

test("picks the first org-tree position unless one is bound", () => {
const tree = { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] };
assert.equal(firstPositionId(tree, "issue-researcher"), "issue-researcher");
assert.equal(firstPositionId(tree), "repo-owner");
});

test("picks a ready engine without inventing host ids", () => {
assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false }, "claude-local": { ready: true } } }), "claude-local");
assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false } } }), null);
});

test("summarizes a completed turn from visible text", () => {
assert.equal(summarizeTurn({ output: { text: "合了 #198" } }), "合了 #198");
});

test("dispatch creates a session and posts a turn", async () => {
const calls = [];
const apiRequest = async (pathname, options = {}) => {
calls.push({ pathname, method: options.method ?? "GET", body: options.body ?? null });
if (pathname === "/workspace") return { status: 200, body: { open: true } };
if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } };
if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: null, sessions: [] } };
if (pathname === "/sessions") return { status: 201, body: { sessionId: "sess-1" } };
if (pathname === "/sessions/sess-1/turns") {
return { status: 200, body: { status: "completed", output: { text: "好的,我去处理" } } };
}
throw new Error(pathname);
};
const result = await dispatchPhoneCommand(apiRequest, "看一下 PR");
assert.equal(result.state, "completed");
assert.equal(result.summary, "好的,我去处理");
assert.equal(calls.some((call) => call.pathname === "/sessions/sess-1/turns" && call.body.input === "看一下 PR"), true);
});

test("dispatch uses the selected position id", async () => {
const paths = [];
const apiRequest = async (pathname, options = {}) => {
paths.push(pathname);
if (pathname === "/workspace") return { status: 200, body: { open: true } };
if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] } };
if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-r" } };
if (pathname === "/sessions/sess-r/turns") {
assert.equal(options.body.input, "去调研");
return { status: 200, body: { status: "completed", output: "ok" } };
}
throw new Error(pathname);
};
const result = await dispatchPhoneCommand(apiRequest, "去调研", "issue-researcher");
assert.equal(result.state, "completed");
assert.equal(paths.includes("/sessions?positionId=issue-researcher"), true);
});

test("live org snapshot omits the workspace path", async () => {
const closed = await collectLiveOrgSnapshot(async () => ({ status: 200, body: { open: false } }));
assert.equal(closed.source, "closed");
const dir = "/secret/workspace-path";
const live = await collectLiveOrgSnapshot(
async () => ({ status: 200, body: { open: true, path: dir } }),
async () => ({ name: "current", owner: "repo-owner", description: "", roles: [{ id: "repo-owner", name: "仓库负责人" }] }),
);
assert.equal(live.source, "live");
assert.equal(live.name, "current");
assert.equal(JSON.stringify(live).includes(dir), false);
});

test("busy employee is reported instead of queued", async () => {
const apiRequest = async (pathname) => {
if (pathname === "/workspace") return { status: 200, body: { open: true } };
if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } };
if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } };
if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-1" } };
if (pathname === "/sessions/sess-1/turns") return { status: 409, body: { code: "session_conflict" } };
throw new Error(pathname);
};
const result = await dispatchPhoneCommand(apiRequest, "再来一条");
assert.equal(result.state, "busy");
});
34 changes: 34 additions & 0 deletions deploy/command.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
--bg: #101116;
--ink: #f5f6fa;
--muted: #a7adbb;
--line: #363946;
--accent: #7aa2ff;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
body { padding: max(20px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); }
main { max-width: 28rem; margin: 0 auto; }
.eyebrow { margin: 0 0 8px; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; font-size: 12px; }
h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; }
.lead, #status { color: var(--muted); line-height: 1.5; }
form { display: grid; gap: 10px; margin: 24px 0; }
label { font-size: 14px; }
input, textarea, button {
width: 100%; font: inherit; color: var(--ink);
border: 1px solid var(--line); border-radius: 12px;
background: #1c1e26;
}
input, textarea { padding: 14px; }
input { letter-spacing: 0.4em; font-size: 22px; text-align: center; }
button {
min-height: 48px; background: var(--accent); color: #101116;
border: 0; font-weight: 650; cursor: pointer;
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
#summary {
white-space: pre-wrap; background: #1c1e26; border: 1px solid var(--line);
border-radius: 12px; padding: 14px; line-height: 1.45;
}
Loading