@tomflow/chatloop-tools 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/bin/cli.mjs +13 -2
- package/lib/adapters/mcp-child.mjs +79 -37
- package/lib/adapters/tunnel.mjs +3 -2
- package/lib/control.mjs +10 -2
- package/lib/gateway.mjs +25 -7
- package/lib/release.mjs +136 -0
- package/lib/supervisor.mjs +51 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools --help
|
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
10
|
Usage: chatloop-tools <command> [target]
|
|
11
|
-
Commands: doctor, mcp, start, stop, restart, status, tunnel, capabilities
|
|
11
|
+
Commands: doctor, mcp, start, stop, restart, status, upgrade, tunnel, capabilities
|
|
12
12
|
Run: chatloop-tools <command> --help
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -22,3 +22,12 @@ Start all enabled tools or one.
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Extension Actions 委托 `@tomflow/chatloop-extension` 公共 CLI,不复制浏览器操作。具体安全边界和协议见 [Local Tools 产品文档](../../docs/local-tools.md),实际部署与已验证状态见 [CURRENT](../../context/CURRENT.md)。
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
正式运行环境的版本切换命令:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
npx --yes --package=@tomflow/chatloop-tools@latest chatloop-tools upgrade
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
升级先独立安装新版本、核实 Supervisor 所有权与运行状态,再通过原 Supervisor 停止旧版、将 Tunnel MCP 启动命令固定到新版本并启动;不在 Tunnel 正处理 MCP 调用时直接自我重启。若返回 `BLOCKED`,先核对原任务回执,不要盲重试。`start` 全量入口在正常用户安装环境检测到版本差异时复用此流程;带隔离状态目录的测试不触发升级。详情见 [Local Tools 主文档](../../docs/local-tools.md)。
|
package/bin/cli.mjs
CHANGED
|
@@ -10,15 +10,17 @@ import {CONTRACT,ACTIONS,registry,ensurePrivateDir,paths,
|
|
|
10
10
|
import {runGateway} from '../lib/gateway.mjs';
|
|
11
11
|
import {tunnelDoctor,runTunnel} from '../lib/tunnel.mjs';
|
|
12
12
|
import {promptTunnelRuntimeKey,storeTunnelRuntimeKey} from '../lib/tunnel-auth.mjs';
|
|
13
|
+
import {upgradeRuntime,runtimeNeedsUpgrade} from '../lib/release.mjs';
|
|
13
14
|
|
|
14
15
|
const HELP={
|
|
15
|
-
'': 'Usage: chatloop-tools <command> [target]\nCommands: doctor, mcp, start, stop, restart, status, tunnel, capabilities\nRun: chatloop-tools <command> --help',
|
|
16
|
+
'': 'Usage: chatloop-tools <command> [target]\nCommands: doctor, mcp, start, stop, restart, status, upgrade, tunnel, capabilities\nRun: chatloop-tools <command> --help',
|
|
16
17
|
doctor:'Usage: chatloop-tools doctor\nInspect local readiness without changing services.',
|
|
17
18
|
mcp:'Usage: chatloop-tools mcp\nServe namespaced MCP tools over stdio.',
|
|
18
19
|
start:'Usage: chatloop-tools start [desktop-commander|tunnel]\nStart all enabled tools or one.',
|
|
19
20
|
stop:'Usage: chatloop-tools stop [desktop-commander|tunnel]\nStop all enabled tools or one.',
|
|
20
21
|
restart:'Usage: chatloop-tools restart [desktop-commander|tunnel]\nRestart all or one.',
|
|
21
22
|
status:'Usage: chatloop-tools status [desktop-commander|tunnel]\nRead runtime state.',
|
|
23
|
+
upgrade:'Usage: chatloop-tools upgrade\nStage an immutable release; verify owner, then switch the owned runtime and Tunnel together.',
|
|
22
24
|
tunnel:'Usage: chatloop-tools tunnel <doctor|auth|--version|run>\nTunnel runs under Supervisor; auth requires explicit setup.',
|
|
23
25
|
capabilities:'Usage: chatloop-tools capabilities\nList supported tool categories.'
|
|
24
26
|
};
|
|
@@ -116,6 +118,15 @@ async function runControl(action=command, params=extra){
|
|
|
116
118
|
if(command==='mcp'){
|
|
117
119
|
if(extra.length){process.stderr.write(JSON.stringify(fail('INVALID_ARGUMENT'))+'\n');process.exitCode=2;}
|
|
118
120
|
else runGateway();
|
|
121
|
+
}else if(command==='upgrade'){
|
|
122
|
+
const result=await upgradeRuntime({args:extra});
|
|
123
|
+
emit(result);if(result.status!=='OK')process.exitCode=result.status==='STARTING'?4:5;
|
|
124
|
+
}else if(command==='start'&&extra.length===0&&
|
|
125
|
+
!process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES&&
|
|
126
|
+
!process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR&&
|
|
127
|
+
process.env.CHAT_LOOP_SKIP_UPGRADE!=='1'&&runtimeNeedsUpgrade()){
|
|
128
|
+
emit({command:'start',status:'BLOCKED',code:'VERSION_MISMATCH_USE_UPGRADE'});
|
|
129
|
+
process.exitCode=5;
|
|
119
130
|
}else if(ACTIONS.has(command))await runControl();
|
|
120
131
|
else if(command==='doctor'){
|
|
121
132
|
if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
|
|
@@ -150,7 +161,7 @@ else if(command==='doctor'){
|
|
|
150
161
|
}else if(command==='capabilities'){
|
|
151
162
|
if(extra.length){emit(fail('INVALID_ARGUMENT'));process.exitCode=2;}
|
|
152
163
|
else emit({contract:'chat-loop.local-tools.v1',command,status:'OK',
|
|
153
|
-
implemented:['capabilities','doctor','mcp','start','stop','restart','status','tunnel'],
|
|
164
|
+
implemented:['capabilities','doctor','mcp','start','stop','restart','status','upgrade','tunnel'],
|
|
154
165
|
lifecycle:'SUPERVISOR_MCP_GATEWAY',mcpTools:'Namespaced tool catalog',
|
|
155
166
|
tunnel:'BUNDLED_SUPERVISOR_MANAGED'});
|
|
156
167
|
}else{emit({contract:'chat-loop.local-tools.v1',command,status:'FAILED',code:'UNKNOWN_COMMAND'});process.exitCode=1;}
|
|
@@ -3,7 +3,7 @@ import {createRequire} from 'node:module';
|
|
|
3
3
|
import {dirname,join} from 'node:path';
|
|
4
4
|
import {mkdirSync,readFileSync} from 'node:fs';
|
|
5
5
|
import {randomUUID} from 'node:crypto';
|
|
6
|
-
import {paths} from '../control.mjs';
|
|
6
|
+
import {paths,toolCallDeadlineMs} from '../control.mjs';
|
|
7
7
|
|
|
8
8
|
const require=createRequire(import.meta.url);
|
|
9
9
|
const catalog=JSON.parse(readFileSync(new URL('../desktop-commander-tools.json',import.meta.url),'utf8'));
|
|
@@ -11,34 +11,53 @@ const fingerprint=tools=>JSON.stringify(tools.map(({name,inputSchema})=>({name,i
|
|
|
11
11
|
const expected=fingerprint(catalog);
|
|
12
12
|
const err=code=>({code});
|
|
13
13
|
const result=(tool,before,after,outcome,error=null,pid=null,evidence=null)=>({toolId:tool.id,enabled:tool.enabled,before,after,outcome,pid,evidence,error});
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
export const startupHandshakeBudget=ctx=>Math.max(0,Math.min(ctx.remaining(),24000));
|
|
14
|
+
// Deadlines bound an individual caller's wait, not a child process' lifetime.
|
|
15
|
+
const timeout=ctx=>Math.max(0,ctx.remaining());
|
|
16
|
+
export const startupHandshakeBudget=ctx=>Math.max(0,ctx.remaining());
|
|
18
17
|
function entryFor(tool){
|
|
19
18
|
if(tool.id!=='desktop-commander')throw Object.assign(Error('Unknown MCP child'),{code:'UNKNOWN_TOOL'});
|
|
20
19
|
const pkg=require.resolve('@wonderwhy-er/desktop-commander/package.json');
|
|
21
20
|
return join(dirname(pkg),'dist','index.js');
|
|
22
21
|
}
|
|
23
|
-
export function createMcpChildAdapter({records,log}){
|
|
24
|
-
|
|
22
|
+
export function createMcpChildAdapter({records,log},options={}){
|
|
23
|
+
const spawnChild=options.spawnChild||spawn;
|
|
24
|
+
const childEntry=options.childEntry||entryFor;
|
|
25
|
+
function request(rec,method,params,ms,onLate=null){
|
|
25
26
|
return new Promise((resolve,reject)=>{
|
|
26
27
|
if(rec.exited||!rec.child.stdin.writable)return reject(Object.assign(Error('Closed'),{code:'TOOL_UNAVAILABLE'}));
|
|
27
|
-
if(ms<1)return reject(Object.assign(Error('
|
|
28
|
+
if(ms!==null&&ms<1)return reject(Object.assign(Error('Caller deadline'),{code:'TIMEOUT'}));
|
|
28
29
|
const id=randomUUID();
|
|
29
|
-
|
|
30
|
+
let settled=false, timer;
|
|
30
31
|
const finish=(error,value)=>{
|
|
31
|
-
clearTimeout(timer);
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
rec.pending.delete(id);
|
|
34
|
+
if(rec.unconfirmed.delete(id)){
|
|
35
|
+
log('late-mcp-reply',{toolId:rec.toolId,requestId:id});
|
|
36
|
+
if(onLate)onLate(error,value);
|
|
37
|
+
}
|
|
38
|
+
if(settled)return;
|
|
39
|
+
settled=true;
|
|
32
40
|
error?reject(error):resolve(value);
|
|
33
41
|
};
|
|
34
42
|
rec.pending.set(id,finish);
|
|
43
|
+
if(ms!==null)timer=setTimeout(()=>{
|
|
44
|
+
if(settled)return;
|
|
45
|
+
settled=true;
|
|
46
|
+
// Keep the callback until the real reply or child exit. Never treat the
|
|
47
|
+
// caller's timeout as evidence that the child is dead or retry the task.
|
|
48
|
+
if(method==='tools/call'){
|
|
49
|
+
rec.unconfirmed.add(id);
|
|
50
|
+
log('mcp-call-unconfirmed',{toolId:rec.toolId,requestId:id});
|
|
51
|
+
}else rec.pending.delete(id);
|
|
52
|
+
reject(Object.assign(Error('Caller deadline'),{code:'TIMEOUT'}));
|
|
53
|
+
},ms);
|
|
35
54
|
rec.child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n',e=>{
|
|
36
55
|
if(e&&rec.pending.has(id))finish(Object.assign(e,{code:'TOOL_UNAVAILABLE'}));
|
|
37
56
|
});
|
|
38
57
|
});
|
|
39
58
|
}
|
|
40
59
|
function setup(tool,child){
|
|
41
|
-
const rec={child,pid:child.pid,state:'STARTING',exited:false,pending:new Map(),active:0};
|
|
60
|
+
const rec={child,toolId:tool.id,pid:child.pid,state:'STARTING',exited:false,pending:new Map(),unconfirmed:new Set(),active:0};
|
|
42
61
|
records.set(tool.id,rec);
|
|
43
62
|
let buffer='';
|
|
44
63
|
child.stdout.on('data',chunk=>{
|
|
@@ -60,58 +79,75 @@ export function createMcpChildAdapter({records,log}){
|
|
|
60
79
|
child.on('exit',(code,signal)=>{
|
|
61
80
|
rec.exited=true;rec.state=rec.state==='STOPPING'?'STOPPED':'FAILED';
|
|
62
81
|
for(const settle of rec.pending.values())settle(Object.assign(Error('Child exit'),{code:'TOOL_UNAVAILABLE'}));
|
|
63
|
-
rec.pending.clear();log('child-exit',{toolId:tool.id,pid:rec.pid,code,signal});
|
|
82
|
+
rec.pending.clear();rec.unconfirmed.clear();log('child-exit',{toolId:tool.id,pid:rec.pid,code,signal});
|
|
64
83
|
});
|
|
65
84
|
return rec;
|
|
66
85
|
}
|
|
67
86
|
async function launch(tool,ctx){
|
|
68
87
|
const old=records.get(tool.id),before=old?.state||'STOPPED';
|
|
69
88
|
if(old&&!old.exited){
|
|
89
|
+
if(old.state==='STARTING')return result(tool,before,'STARTING','HANDSHAKE_PENDING',null,old.pid,'MCP_INITIALIZING');
|
|
90
|
+
if(old.unconfirmed.size)return result(tool,before,'DEGRADED','UNCONFIRMED_OPERATIONS',err('OPERATION_UNCONFIRMED'),old.pid,'MCP_REPLY_PENDING');
|
|
70
91
|
if(old.state!=='RUNNING')return result(tool,before,'UNKNOWN','IDENTITY_UNCERTAIN',err('UNKNOWN_RESULT'),old.pid);
|
|
71
92
|
const health=await inspect(tool,ctx);
|
|
72
93
|
if(health.after==='RUNNING')return result(tool,before,'RUNNING','ALREADY_RUNNING',null,old.pid,'MCP_TOOLS_LIST');
|
|
73
|
-
return
|
|
94
|
+
return {...health,outcome:'HEALTH_NOT_CONFIRMED'};
|
|
74
95
|
}
|
|
75
|
-
if(startupHandshakeBudget(ctx)<100)return result(tool,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'));
|
|
76
96
|
const home=join(paths().dir,'tools',tool.id,'home');
|
|
77
97
|
mkdirSync(home,{recursive:true,mode:0o700});
|
|
78
98
|
const env={HOME:home,PATH:process.env.PATH||'/usr/bin:/bin',LANG:process.env.LANG||'en_US.UTF-8',
|
|
79
99
|
TMPDIR:process.env.TMPDIR||'/tmp',USER:process.env.USER||'',DESKTOP_COMMANDER_DISABLE_TELEMETRY:'true',
|
|
80
100
|
DC_REMOTE_DEVICE:'false'};
|
|
81
|
-
const child=
|
|
101
|
+
const child=spawnChild(process.execPath,[childEntry(tool),'--no-onboarding'],
|
|
82
102
|
{stdio:['pipe','pipe','pipe'],env,windowsHide:true});
|
|
83
103
|
const rec=setup(tool,child);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
// Initialization may take arbitrarily long. The CLI can return STARTING;
|
|
105
|
+
// the same owned child will become RUNNING when its MCP reply arrives.
|
|
106
|
+
const handshake=(async()=>{
|
|
107
|
+
try{
|
|
108
|
+
const info=await request(rec,'initialize',{protocolVersion:'2025-03-26',
|
|
109
|
+
capabilities:{},clientInfo:{name:'chat-loop-local-tools',version:'0.4.4'}},null);
|
|
110
|
+
if(!info?.protocolVersion)throw Object.assign(Error('Handshake failed'),{code:'MCP_HANDSHAKE_FAILED'});
|
|
111
|
+
child.stdin.write(JSON.stringify({jsonrpc:'2.0',method:'notifications/initialized'})+'\n');
|
|
112
|
+
const listed=await request(rec,'tools/list',{},null);
|
|
113
|
+
if(!listed||fingerprint(listed.tools||[])!==expected)
|
|
114
|
+
throw Object.assign(Error('Tool schema changed'),{code:'SCHEMA_MISMATCH'});
|
|
115
|
+
if(rec.exited||rec.state==='STOPPING')return result(tool,before,'FAILED','START_FAILED',err('CHILD_EXIT'),rec.pid);
|
|
116
|
+
rec.state='RUNNING';log('tool-start',{requestId:ctx.requestId,toolId:tool.id,pid:rec.pid});
|
|
117
|
+
return result(tool,before,'RUNNING','STARTED',null,rec.pid,'MCP_TOOLS_LIST');
|
|
118
|
+
}catch(e){
|
|
119
|
+
if(!rec.exited)rec.state='UNKNOWN';
|
|
120
|
+
return result(tool,before,'UNKNOWN','START_FAILED',err(e.code||'UNKNOWN_RESULT'),rec.pid,'NO_VALID_HANDSHAKE');
|
|
121
|
+
}
|
|
122
|
+
})();
|
|
123
|
+
const wait=ctx.remaining();
|
|
124
|
+
if(wait<1)return result(tool,before,'STARTING','HANDSHAKE_PENDING',null,rec.pid,'MCP_INITIALIZING');
|
|
125
|
+
return Promise.race([
|
|
126
|
+
handshake,
|
|
127
|
+
new Promise(resolve=>setTimeout(()=>resolve(result(tool,before,'STARTING','HANDSHAKE_PENDING',null,rec.pid,'MCP_INITIALIZING')),wait))
|
|
128
|
+
]);
|
|
100
129
|
}
|
|
101
130
|
async function inspect(tool,ctx){
|
|
102
131
|
const rec=records.get(tool.id);
|
|
103
132
|
if(!rec)return result(tool,'STOPPED','STOPPED','OBSERVED',null,null,'NO_OWNED_PROCESS');
|
|
104
133
|
if(rec.exited)return result(tool,rec.state,rec.state,'OBSERVED',null,null,'CHILD_EXIT');
|
|
105
|
-
if(rec.state
|
|
134
|
+
if(rec.state==='STARTING')return result(tool,'STARTING','STARTING','OBSERVED',null,rec.pid,'MCP_INITIALIZING');
|
|
135
|
+
if(rec.state!=='RUNNING'&&rec.state!=='UNKNOWN')return result(tool,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid);
|
|
136
|
+
if(rec.unconfirmed.size)return result(tool,'RUNNING','DEGRADED','OBSERVED',err('OPERATION_UNCONFIRMED'),rec.pid,'MCP_REPLY_PENDING');
|
|
137
|
+
if(rec.active)return result(tool,'RUNNING','DEGRADED','OBSERVED',err('ACTIVE_REQUESTS'),rec.pid,'MCP_CALL_IN_FLIGHT');
|
|
106
138
|
try{
|
|
107
139
|
const listed=await request(rec,'tools/list',{},Math.min(timeout(ctx),1000));
|
|
108
140
|
if(fingerprint(listed.tools||[])!==expected)throw Object.assign(Error('Changed schema'),{code:'SCHEMA_MISMATCH'});
|
|
109
|
-
return result(tool,'RUNNING','RUNNING','OBSERVED',null,rec.pid,'MCP_TOOLS_LIST');
|
|
110
|
-
}catch(e){
|
|
141
|
+
rec.state='RUNNING';return result(tool,'RUNNING','RUNNING','OBSERVED',null,rec.pid,'MCP_TOOLS_LIST');
|
|
142
|
+
}catch(e){
|
|
143
|
+
// One unanswered health probe does not mutate the running child state.
|
|
144
|
+
return result(tool,'RUNNING','DEGRADED','OBSERVED',err(e.code||'UNKNOWN_RESULT'),rec.pid,'MCP_PROBE_UNANSWERED');
|
|
145
|
+
}
|
|
111
146
|
}
|
|
112
147
|
async function stop(tool,ctx){
|
|
113
148
|
const rec=records.get(tool.id),before=rec?.state||'STOPPED';
|
|
114
149
|
if(!rec||rec.exited)return result(tool,before,'STOPPED','ALREADY_STOPPED',null,null,'NO_LIVE_OWNED_CHILD');
|
|
150
|
+
if(rec.unconfirmed.size||rec.active)return result(tool,before,'DEGRADED','ACTIVE_REQUESTS',err('OPERATION_UNCONFIRMED'),rec.pid,'MCP_REPLY_PENDING');
|
|
115
151
|
if(timeout(ctx)<1)return result(tool,before,before,'DEADLINE_EXPIRED',err('TIMEOUT'),rec.pid,'NO_SIGNAL_SENT');
|
|
116
152
|
rec.state='STOPPING';
|
|
117
153
|
// A direct ChildProcess handle only. Never kill by saved PID or process name.
|
|
@@ -129,11 +165,17 @@ export function createMcpChildAdapter({records,log}){
|
|
|
129
165
|
const rec=records.get(tool.id);
|
|
130
166
|
if(!rec||rec.exited||rec.state!=='RUNNING')throw Object.assign(Error('Not running'),{code:'TOOL_UNAVAILABLE'});
|
|
131
167
|
if(!catalog.some(t=>t.name===name))throw Object.assign(Error('No such tool'),{code:'UNKNOWN_TOOL'});
|
|
168
|
+
// A prior timed-out write may still be running. Read-only probes may still
|
|
169
|
+
// proceed, but never enqueue another uncertain mutating operation.
|
|
170
|
+
const harmless=new Set(['read_file','read_multiple_files','list_directory','get_file_info',
|
|
171
|
+
'list_sessions','list_processes','get_usage_stats','get_recent_tool_calls']);
|
|
172
|
+
if(rec.unconfirmed.size&&!harmless.has(name))
|
|
173
|
+
throw Object.assign(Error('Previous request outcome unconfirmed'),{code:'OPERATION_UNCONFIRMED'});
|
|
132
174
|
rec.active++;
|
|
133
175
|
try{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
finally{rec.active--;}
|
|
176
|
+
const ms=Math.max(0,Math.min(ctx.remaining(),toolCallDeadlineMs(args)-2000));
|
|
177
|
+
return await request(rec,'tools/call',{name,arguments:args},ms,(error,value)=>ctx.onLate?.(error,value));
|
|
178
|
+
}finally{rec.active--;}
|
|
137
179
|
}
|
|
138
180
|
return {launch,stop,inspect,call,catalog};
|
|
139
181
|
}
|
package/lib/adapters/tunnel.mjs
CHANGED
|
@@ -25,7 +25,7 @@ function probe(socketPath,ms=350){
|
|
|
25
25
|
function safeUnlink(file){
|
|
26
26
|
try{unlinkSync(file);}catch(e){if(e.code!=='ENOENT')throw e;}
|
|
27
27
|
}
|
|
28
|
-
export function createTunnelAdapter({records,log},options={}){
|
|
28
|
+
export function createTunnelAdapter({records,log,onChildExit=()=>{}},options={}){
|
|
29
29
|
const locate=options.locate||bundledTunnel;
|
|
30
30
|
const spawnChild=options.spawnChild||spawn;
|
|
31
31
|
const profileDir=options.profileDir||join(homedir(),'.chat-loop','tunnel','profiles');
|
|
@@ -68,7 +68,7 @@ export function createTunnelAdapter({records,log},options={}){
|
|
|
68
68
|
if(rec.state==='STOPPING'||rec.stopSignaled)
|
|
69
69
|
return result(t,rec.state,'UNKNOWN','OBSERVED',err('UNKNOWN_RESULT'),rec.pid,'STOP_UNCONFIRMED');
|
|
70
70
|
const before=rec.state,ready=await probe(socket);
|
|
71
|
-
if(!ready)
|
|
71
|
+
if(!ready)return result(t,before,'DEGRADED','OBSERVED',err('READINESS_UNCONFIRMED'),rec.pid,'READYZ_NOT_200');
|
|
72
72
|
rec.state='RUNNING';
|
|
73
73
|
return result(t,before,'RUNNING','OBSERVED',null,rec.pid,'LOCAL_READYZ');
|
|
74
74
|
}
|
|
@@ -118,6 +118,7 @@ export function createTunnelAdapter({records,log},options={}){
|
|
|
118
118
|
if(rec.exited)return;
|
|
119
119
|
rec.exited=true;rec.state=rec.state==='STOPPING'?'STOPPED':'FAILED';
|
|
120
120
|
forget(rec);log('tunnel-exit',{toolId:t.id,pid:rec.pid,code,signal});
|
|
121
|
+
if(rec.state==='FAILED'&&!rec.authRequired)onChildExit(t,rec);
|
|
121
122
|
}
|
|
122
123
|
child.once('exit',onExit);
|
|
123
124
|
// spawn ENOENT emits error then close, potentially without an exit event.
|
package/lib/control.mjs
CHANGED
|
@@ -4,7 +4,15 @@ import {homedir} from 'node:os';
|
|
|
4
4
|
import {join} from 'node:path';
|
|
5
5
|
|
|
6
6
|
export const CONTRACT = 'chat-loop.local-control.v1';
|
|
7
|
-
export const
|
|
7
|
+
export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
8
|
+
// All peers must be from one published runtime; never mix an old npx Gateway
|
|
9
|
+
// with a newly activated Supervisor under an unchanged protocol banner.
|
|
10
|
+
export const VERSION = PACKAGE_VERSION;
|
|
11
|
+
// Bounded end-to-end tool calls: long-running Desktop Commander operations may wait up to timeout_ms.
|
|
12
|
+
export const toolCallDeadlineMs = args => {
|
|
13
|
+
const requested = Number.isSafeInteger(args?.timeout_ms) && args.timeout_ms > 0 ? args.timeout_ms : 0;
|
|
14
|
+
return Math.min(42000, Math.max(12000, requested + 8000));
|
|
15
|
+
};
|
|
8
16
|
export const ACTIONS = new Set(['start','stop','restart','status']);
|
|
9
17
|
export const directory = () => process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR || join(homedir(), '.chat-loop', 'local-tools');
|
|
10
18
|
export const paths = () => ({dir: directory(), socket: join(directory(), 'control.sock'), lock: join(directory(), 'manager.lock'), log: join(directory(), 'events.jsonl')});
|
|
@@ -77,7 +85,7 @@ export function send(request, timeout=3000) {
|
|
|
77
85
|
}
|
|
78
86
|
export async function talk(request,timeout=12000){
|
|
79
87
|
const hello=await send({action:'__ping'},1200);
|
|
80
|
-
if(hello.contract!==CONTRACT||hello.version!==VERSION||
|
|
88
|
+
if(hello.contract!==CONTRACT||hello.version!==VERSION||hello.packageVersion!==PACKAGE_VERSION||
|
|
81
89
|
hello.registrySignature!==registry().map(t=>t.id+':'+t.enabled).join('|'))
|
|
82
90
|
throw Object.assign(new Error('Version or registry mismatch'),{code:'INCOMPATIBLE_VERSION'});
|
|
83
91
|
verifyOwner(hello);
|
package/lib/gateway.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Stdio MCP entrypoint: protocol framing and routing only; never owns child processes.
|
|
2
2
|
import {randomUUID} from 'node:crypto';
|
|
3
3
|
import {readFileSync} from 'node:fs';
|
|
4
|
-
import {CONTRACT,registry,talk} from './control.mjs';
|
|
4
|
+
import {CONTRACT,PACKAGE_VERSION,registry,talk,toolCallDeadlineMs} from './control.mjs';
|
|
5
5
|
import {extensionTools,forwardExtensionAction} from './extension-proxy.mjs';
|
|
6
|
+
import {runtimeNeedsUpgrade} from './release.mjs';
|
|
6
7
|
|
|
7
8
|
const desktopTools=JSON.parse(readFileSync(new URL('./desktop-commander-tools.json',import.meta.url),'utf8'));
|
|
8
9
|
const fixtureTool={name:'ping',description:'Inert lifecycle test health check',
|
|
@@ -12,10 +13,15 @@ const prefix=id=>id.replaceAll('-','_')+'__';
|
|
|
12
13
|
// The pinned child's UI metadata advertises resources this tools-only Gateway does not serve.
|
|
13
14
|
// Do not expose those references until resources/read is explicitly supported.
|
|
14
15
|
const extEnabled=()=>process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES!=='1';
|
|
16
|
+
const receiptTool={name:'chat_loop_runtime__operation_status',
|
|
17
|
+
description:'Read a previously timed-out MCP request receipt without replaying its side effects.',
|
|
18
|
+
inputSchema:{type:'object',required:['requestId'],additionalProperties:false,
|
|
19
|
+
properties:{requestId:{type:'string'}}}};
|
|
15
20
|
const published=()=>[
|
|
16
21
|
...registry().filter(t=>t.enabled).flatMap(t=>
|
|
17
22
|
toolsFor(t).map(({_meta,...x})=>({...x,name:prefix(t.id)+x.name}))),
|
|
18
|
-
...(extEnabled()?extensionTools.map(t=>({...t,name:'chat_loop_extension__'+t.name})):[])
|
|
23
|
+
...(extEnabled()?extensionTools.map(t=>({...t,name:'chat_loop_extension__'+t.name})):[]),
|
|
24
|
+
receiptTool
|
|
19
25
|
];
|
|
20
26
|
function resolveTool(name){
|
|
21
27
|
if(extEnabled()&&typeof name==='string'&&name.startsWith('chat_loop_extension__')){
|
|
@@ -46,7 +52,7 @@ export function runGateway(){
|
|
|
46
52
|
return reject(m.id,-32602,'UNSUPPORTED_MCP_VERSION');
|
|
47
53
|
connected=true;
|
|
48
54
|
return reply(m.id,{protocolVersion:m.params.protocolVersion,
|
|
49
|
-
capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:
|
|
55
|
+
capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:PACKAGE_VERSION}});
|
|
50
56
|
}
|
|
51
57
|
// A modern MCP client probes discovery before falling back to legacy initialize.
|
|
52
58
|
// We only implement the legacy handshake era, so report unsupported method.
|
|
@@ -55,17 +61,29 @@ export function runGateway(){
|
|
|
55
61
|
if(m.method==='ping')return reply(m.id,{});
|
|
56
62
|
if(m.method==='tools/list')return reply(m.id,{tools:published()});
|
|
57
63
|
if(m.method==='tools/call'){
|
|
58
|
-
const routed=
|
|
64
|
+
const routed=m.params?.name===receiptTool.name?
|
|
65
|
+
{toolId:'runtime',name:'operation_status'}:resolveTool(m.params?.name);
|
|
59
66
|
if(!routed)return reply(m.id,failure('UNKNOWN_TOOL'));
|
|
60
67
|
const args=m.params?.arguments??{};
|
|
61
68
|
if(args===null||typeof args!=='object'||Array.isArray(args))
|
|
62
69
|
return reply(m.id,failure('INVALID_ARGUMENT'));
|
|
63
70
|
try{
|
|
71
|
+
if(routed.toolId==='runtime'){
|
|
72
|
+
const res=await talk({contract:CONTRACT,action:'receipt',requestId:randomUUID(),
|
|
73
|
+
targetRequestId:args.requestId},4000);
|
|
74
|
+
return reply(m.id,res.status==='OK'?{operation:res.operation}:failure('RECEIPT_UNAVAILABLE'));
|
|
75
|
+
}
|
|
76
|
+
if(routed.toolId!=='chat-loop-extension'&&
|
|
77
|
+
!process.env.CHAT_LOOP_LOCAL_TOOLS_STATE_DIR&&
|
|
78
|
+
!process.env.CHAT_LOOP_LOCAL_TOOLS_FIXTURES&&runtimeNeedsUpgrade())
|
|
79
|
+
return reply(m.id,failure('RUNTIME_UPGRADE_REQUIRED'));
|
|
64
80
|
if(routed.toolId==='chat-loop-extension')
|
|
65
81
|
return reply(m.id,await forwardExtensionAction(routed.name,args));
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
const deadlineMs=toolCallDeadlineMs(args),requestId=randomUUID();
|
|
83
|
+
const res=await talk({contract:CONTRACT,requestId,action:'call',
|
|
84
|
+
scope:'tool',toolId:routed.toolId,name:routed.name,args,deadlineMs},deadlineMs+2000);
|
|
85
|
+
if(res.status!=='OK')return reply(m.id,failure(res.error?.code==='TIMEOUT'?
|
|
86
|
+
'OPERATION_UNCONFIRMED:'+requestId:res.error?.code||'TOOL_UNAVAILABLE'));
|
|
69
87
|
return reply(m.id,res.result);
|
|
70
88
|
}catch(e){
|
|
71
89
|
return reply(m.id,failure(['ENOENT','ECONNREFUSED'].includes(e.code)?'TOOL_UNAVAILABLE':
|
package/lib/release.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Owner-verified stop-and-start upgrade. Failure is reported, never auto-rolled back.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {existsSync,readFileSync,writeFileSync,openSync,closeSync,unlinkSync,mkdirSync,renameSync} from 'node:fs';
|
|
5
|
+
import {homedir} from 'node:os';
|
|
6
|
+
import {join,dirname} from 'node:path';
|
|
7
|
+
import {PACKAGE_VERSION} from './control.mjs';
|
|
8
|
+
|
|
9
|
+
const name='@tomflow/chatloop-tools';
|
|
10
|
+
const entryIn=prefix=>join(prefix,'node_modules','@tomflow','chatloop-tools','bin','cli.mjs');
|
|
11
|
+
const runtimeRoot=()=>join(homedir(),'.chat-loop');
|
|
12
|
+
const readJson=path=>JSON.parse(readFileSync(path,'utf8'));
|
|
13
|
+
const delay=ms=>new Promise(r=>setTimeout(r,ms));
|
|
14
|
+
const safeVersion=v=>/^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.-]+)?$/.test(v);
|
|
15
|
+
const quote=s=>JSON.stringify(s);
|
|
16
|
+
const freshResult=(status,extra={})=>({command:'upgrade',status,version:PACKAGE_VERSION,...extra});
|
|
17
|
+
|
|
18
|
+
export function upgradeLayout(base=runtimeRoot(),version=PACKAGE_VERSION){
|
|
19
|
+
if(!safeVersion(version))throw Error('INVALID_VERSION');
|
|
20
|
+
return {
|
|
21
|
+
base,version,
|
|
22
|
+
releases:join(base,'releases'),
|
|
23
|
+
stage:join(base,'releases','chatloop-tools-'+version),
|
|
24
|
+
entry:entryIn(join(base,'releases','chatloop-tools-'+version)),
|
|
25
|
+
legacy:entryIn(join(base,'npm')),
|
|
26
|
+
profile:join(base,'tunnel','profiles','chat-loop.yaml'),
|
|
27
|
+
current:join(base,'runtime.json'),
|
|
28
|
+
lock:join(base,'upgrade.lock')
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function atomicWrite(path,content){
|
|
32
|
+
const tmp=path+'.'+randomUUID()+'.tmp';
|
|
33
|
+
writeFileSync(tmp,content,{mode:0o600,flag:'wx'});
|
|
34
|
+
renameSync(tmp,path);
|
|
35
|
+
}
|
|
36
|
+
function run(bin,args,opts={}){
|
|
37
|
+
return new Promise((resolve,reject)=>{
|
|
38
|
+
const child=spawn(bin,args,{env:opts.env||process.env,stdio:['ignore','pipe','pipe'],windowsHide:true});
|
|
39
|
+
let stdout='',stderr='';
|
|
40
|
+
child.stdout.on('data',x=>stdout+=x.toString());
|
|
41
|
+
child.stderr.on('data',x=>stderr+=x.toString().slice(0,2048));
|
|
42
|
+
child.once('error',reject);
|
|
43
|
+
child.once('close',code=>code===0?resolve(stdout):reject(Object.assign(Error('COMMAND_FAILED'),{
|
|
44
|
+
code:'COMMAND_FAILED',command:args[0],status:code,stdout,detail:stderr.slice(-300)})));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function cli(entry,action,opts={}){
|
|
48
|
+
let raw;
|
|
49
|
+
try{raw=await run(process.execPath,[entry,action],opts);}
|
|
50
|
+
catch(e){if(!e.stdout)throw e;raw=e.stdout;}
|
|
51
|
+
try{return JSON.parse(raw.trim());}
|
|
52
|
+
catch{throw Object.assign(Error('INVALID_CLI_REPLY'),{code:'INVALID_CLI_REPLY'});}
|
|
53
|
+
}
|
|
54
|
+
async function awaitStopped(entry,opts={}){
|
|
55
|
+
// A completed stop is verified by the former owner's lock disappearing.
|
|
56
|
+
const p=opts.lockPath;
|
|
57
|
+
for(let i=0;i<80;i++){
|
|
58
|
+
if(!existsSync(p))return;
|
|
59
|
+
await delay(100);
|
|
60
|
+
}
|
|
61
|
+
throw Object.assign(Error('OWNER_STILL_RUNNING'),{code:'OWNER_STILL_RUNNING'});
|
|
62
|
+
}
|
|
63
|
+
export function runtimeNeedsUpgrade(base=runtimeRoot(),version=PACKAGE_VERSION){
|
|
64
|
+
const p=upgradeLayout(base,version);
|
|
65
|
+
const runtime=existsSync(p.current)?readJson(p.current):null;
|
|
66
|
+
if(runtime)return runtime.version!==version;
|
|
67
|
+
if(!existsSync(p.legacy))return false;
|
|
68
|
+
const current=readJson(join(dirname(dirname(p.legacy)),'package.json')).version;
|
|
69
|
+
return current!==version;
|
|
70
|
+
}
|
|
71
|
+
export async function upgradeRuntime(options={}){
|
|
72
|
+
if(options.args?.length)return freshResult('FAILED',{code:'INVALID_ARGUMENT'});
|
|
73
|
+
const p=upgradeLayout(options.base,options.version||PACKAGE_VERSION);
|
|
74
|
+
mkdirSync(p.base,{recursive:true,mode:0o700});
|
|
75
|
+
mkdirSync(p.releases,{recursive:true,mode:0o700});
|
|
76
|
+
let fd;
|
|
77
|
+
try{fd=openSync(p.lock,'wx',0o600);}
|
|
78
|
+
catch{return freshResult('BLOCKED',{code:'UPGRADE_IN_PROGRESS'});}
|
|
79
|
+
const runner=options.run||run;
|
|
80
|
+
let oldEntry, oldStatus, phase='preflight';
|
|
81
|
+
try{
|
|
82
|
+
if(!existsSync(p.profile))return freshResult('BLOCKED',{code:'PROFILE_NOT_FOUND'});
|
|
83
|
+
const profile=readJson(p.profile);
|
|
84
|
+
if(!profile?.mcp?.commands?.some(x=>x.channel==='main'))
|
|
85
|
+
return freshResult('BLOCKED',{code:'PROFILE_MCP_NOT_FOUND'});
|
|
86
|
+
const runtime=existsSync(p.current)?readJson(p.current):null;
|
|
87
|
+
oldEntry=runtime?.entry||p.legacy;
|
|
88
|
+
if(!existsSync(oldEntry))return freshResult('BLOCKED',{code:'PREVIOUS_RUNTIME_NOT_FOUND'});
|
|
89
|
+
const currentVersion=readJson(join(dirname(dirname(oldEntry)),'package.json')).version;
|
|
90
|
+
if(currentVersion===p.version&&runtime?.entry===p.entry){
|
|
91
|
+
const active=await cli(p.entry,'status');
|
|
92
|
+
return active.status==='OK'?freshResult('OK',{outcome:'ALREADY_ACTIVE'}):
|
|
93
|
+
freshResult('BLOCKED',{code:'ACTIVE_RUNTIME_NOT_HEALTHY'});
|
|
94
|
+
}
|
|
95
|
+
// Prepare a new immutable install without touching the running service.
|
|
96
|
+
phase='prepare';
|
|
97
|
+
if(!existsSync(p.entry)){
|
|
98
|
+
await runner(process.platform==='win32'?'npm.cmd':'npm',
|
|
99
|
+
['install','--prefix',p.stage,'--save-exact','--ignore-scripts',name+'@'+p.version],
|
|
100
|
+
{env:process.env});
|
|
101
|
+
}
|
|
102
|
+
if(!existsSync(p.entry)||readJson(join(dirname(dirname(p.entry)),'package.json')).version!==p.version)
|
|
103
|
+
return freshResult('BLOCKED',{code:'STAGE_VERSION_MISMATCH'});
|
|
104
|
+
const ready=await cli(p.entry,'doctor');
|
|
105
|
+
if(ready.status!=='LOCAL_READY')return freshResult('BLOCKED',{code:'STAGE_DOCTOR_FAILED'});
|
|
106
|
+
// The staged Gateway is prepared independently; Tunnel binds only on activation.
|
|
107
|
+
phase='check-owner';
|
|
108
|
+
oldStatus=await cli(oldEntry,'status');
|
|
109
|
+
if(oldStatus.status!=='OK'||oldStatus.results?.some(r=>r.after!=='RUNNING'&&r.after!=='STOPPED'&&r.after!=='DISABLED'))
|
|
110
|
+
return freshResult('BLOCKED',{code:'OWNER_NOT_QUIESCENT',previousStatus:oldStatus.status});
|
|
111
|
+
phase='stop-old';
|
|
112
|
+
const oldStopped=await cli(oldEntry,'stop');
|
|
113
|
+
if(oldStopped.status!=='OK')return freshResult('BLOCKED',{code:'STOP_UNCONFIRMED'});
|
|
114
|
+
phase='verify-stopped';
|
|
115
|
+
await awaitStopped(oldEntry,{lockPath:join(p.base,'local-tools','manager.lock')});
|
|
116
|
+
phase='configure-new';
|
|
117
|
+
const main=profile.mcp.commands.find(x=>x.channel==='main');
|
|
118
|
+
main.command=quote(process.execPath)+' '+quote(p.entry)+' mcp';
|
|
119
|
+
atomicWrite(p.profile,JSON.stringify(profile,null,2)+'\n');
|
|
120
|
+
phase='start-new';
|
|
121
|
+
const started=await cli(p.entry,'start',{env:{...process.env,CHAT_LOOP_SKIP_UPGRADE:'1'}});
|
|
122
|
+
if(!['OK','STARTING'].includes(started.status))
|
|
123
|
+
throw Object.assign(Error('NEW_RUNTIME_UNHEALTHY'),{code:'NEW_RUNTIME_UNHEALTHY'});
|
|
124
|
+
phase='record-new';
|
|
125
|
+
atomicWrite(p.current,JSON.stringify({version:p.version,entry:p.entry,
|
|
126
|
+
status:started.status==='OK'?'ACTIVE':'STARTING'},null,2)+'\n');
|
|
127
|
+
return freshResult(started.status,{outcome:started.status==='OK'?'ACTIVATED':'STARTING'});
|
|
128
|
+
}catch(e){
|
|
129
|
+
// Fail closed and retain the exact failure site. Never restart an unknown
|
|
130
|
+
// old/new owner or mutate another process as a "recovery" side effect.
|
|
131
|
+
return freshResult('BLOCKED',{code:e.code||'UPGRADE_FAILED',phase});
|
|
132
|
+
}finally{
|
|
133
|
+
if(fd!==undefined)closeSync(fd);
|
|
134
|
+
try{unlinkSync(p.lock);}catch{}
|
|
135
|
+
}
|
|
136
|
+
}
|
package/lib/supervisor.mjs
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
import {randomUUID} from 'node:crypto';
|
|
2
2
|
import {appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, chmodSync} from 'node:fs';
|
|
3
3
|
import {createServer} from 'node:net';
|
|
4
|
-
import {CONTRACT,VERSION,registry,paths,ensurePrivateDir,ACTIONS} from './control.mjs';
|
|
4
|
+
import {CONTRACT,VERSION,PACKAGE_VERSION,registry,paths,ensurePrivateDir,ACTIONS} from './control.mjs';
|
|
5
5
|
import {createAdapter} from './adapters/index.mjs';
|
|
6
6
|
|
|
7
|
-
const catalog=registry(), records=new Map(), queues=new Map(), seen=new Map();
|
|
7
|
+
const catalog=registry(), records=new Map(), queues=new Map(), seen=new Map(), receipts=new Map();
|
|
8
8
|
const p=paths(), nonce=randomUUID();
|
|
9
|
+
const desiredTunnel=new Set(), recoveryAttempts=new Map();
|
|
9
10
|
let lockFd, server, idleTimer;
|
|
10
11
|
const log=(event,detail={})=>{
|
|
11
12
|
try{appendFileSync(p.log,JSON.stringify({time:new Date().toISOString(),event,...detail})+'\n',{mode:0o600});}catch{}
|
|
12
13
|
};
|
|
13
14
|
const error=(code,message=code)=>({code,message});
|
|
14
15
|
const after=rec=>rec ? rec.state : 'STOPPED';
|
|
16
|
+
function saveReceipt(id,state,result=null,errorCode=null){
|
|
17
|
+
receipts.delete(id);
|
|
18
|
+
receipts.set(id,{state,result,errorCode});
|
|
19
|
+
if(receipts.size>128)receipts.delete(receipts.keys().next().value);
|
|
20
|
+
}
|
|
21
|
+
function receipt(req){
|
|
22
|
+
const id=req?.targetRequestId;
|
|
23
|
+
if(req?.contract!==CONTRACT||typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))
|
|
24
|
+
return bad(req,'INVALID_ARGUMENT');
|
|
25
|
+
const record=receipts.get(id);
|
|
26
|
+
return {contract:CONTRACT,requestId:req.requestId,status:'OK',
|
|
27
|
+
operation:record||{state:'UNKNOWN',result:null,errorCode:'NOT_FOUND'}};
|
|
28
|
+
}
|
|
15
29
|
function serial(id,fn){
|
|
16
30
|
const prev=queues.get(id)||Promise.resolve();
|
|
17
31
|
const next=prev.catch(()=>{}).then(fn);
|
|
@@ -21,9 +35,24 @@ function serial(id,fn){
|
|
|
21
35
|
}
|
|
22
36
|
const adapters=new Map();
|
|
23
37
|
function adapterFor(tool){
|
|
24
|
-
if(!adapters.has(tool.kind))adapters.set(tool.kind,createAdapter(tool.kind,{records,log}));
|
|
38
|
+
if(!adapters.has(tool.kind))adapters.set(tool.kind,createAdapter(tool.kind,{records,log,onChildExit:queueTunnelRecovery}));
|
|
25
39
|
return adapters.get(tool.kind);
|
|
26
40
|
}
|
|
41
|
+
function queueTunnelRecovery(tool){
|
|
42
|
+
if(!desiredTunnel.has(tool.id))return;
|
|
43
|
+
const now=Date.now(), old=recoveryAttempts.get(tool.id)||[];
|
|
44
|
+
const attempts=old.filter(t=>now-t<300000);
|
|
45
|
+
if(attempts.length>=3){desiredTunnel.delete(tool.id);log('tunnel-recovery-blocked',{toolId:tool.id,reason:'REPEATED_CRASH'});return;}
|
|
46
|
+
attempts.push(now);recoveryAttempts.set(tool.id,attempts);
|
|
47
|
+
// Only the same Supervisor and its owned child handle can relaunch. Explicit
|
|
48
|
+
// stop/update disables the desired state before the old child is signalled.
|
|
49
|
+
void serial(tool.id,async()=>{
|
|
50
|
+
if(!desiredTunnel.has(tool.id))return;
|
|
51
|
+
const reply=await adapterFor(tool).launch(tool,{requestId:'tunnel-recovery',remaining:()=>25000});
|
|
52
|
+
log('tunnel-recovery',{toolId:tool.id,status:reply.after,outcome:reply.outcome});
|
|
53
|
+
if(reply.after!=='RUNNING')desiredTunnel.delete(tool.id);
|
|
54
|
+
}).catch(e=>log('tunnel-recovery-error',{toolId:tool.id,code:e.code||'UNKNOWN_RESULT'}));
|
|
55
|
+
}
|
|
27
56
|
function targets(req){
|
|
28
57
|
const found=req.scope==='tool'?catalog.filter(t=>t.id===req.toolId):
|
|
29
58
|
catalog.filter(t=>req.action==='status'||t.enabled);
|
|
@@ -40,7 +69,7 @@ async function executeCall(req){
|
|
|
40
69
|
if(typeof req.requestId!=='string'||req.requestId.length<6||req.requestId.length>128||
|
|
41
70
|
req.scope!=='tool'||typeof req.toolId!=='string'||typeof req.name!=='string'||
|
|
42
71
|
!req.args||typeof req.args!=='object'||Array.isArray(req.args)||
|
|
43
|
-
!Number.isInteger(req.deadlineMs)||req.deadlineMs<1000||req.deadlineMs>
|
|
72
|
+
!Number.isInteger(req.deadlineMs)||req.deadlineMs<1000||req.deadlineMs>45000)return invalid();
|
|
44
73
|
const tool=catalog.find(t=>t.id===req.toolId);
|
|
45
74
|
if(!tool)return bad(req,'UNKNOWN_TOOL');
|
|
46
75
|
if(!tool.enabled)return bad(req,'DISABLED_TOOL');
|
|
@@ -48,16 +77,21 @@ async function executeCall(req){
|
|
|
48
77
|
const prev=seen.get(req.requestId);
|
|
49
78
|
if(prev)return prev.payload===argsKey?prev.promise:bad(req,'REQUEST_ID_REUSE');
|
|
50
79
|
const expires=Date.now()+req.deadlineMs;
|
|
80
|
+
saveReceipt(req.requestId,'RUNNING');
|
|
51
81
|
const promise=serial(tool.id,async()=>{
|
|
52
82
|
try{
|
|
53
83
|
const adapter=adapterFor(tool);
|
|
54
84
|
if(typeof adapter.call!=='function')return bad(req,'TOOL_UNAVAILABLE');
|
|
55
85
|
const value=await adapter.call(tool,req.name,req.args,
|
|
56
|
-
{requestId:req.requestId,remaining:()=>Math.max(0,expires-Date.now())
|
|
86
|
+
{requestId:req.requestId,remaining:()=>Math.max(0,expires-Date.now()),
|
|
87
|
+
onLate:(error,value)=>saveReceipt(req.requestId,error?'FAILED':'COMPLETED',error?null:value,
|
|
88
|
+
error?.code||null)});
|
|
89
|
+
saveReceipt(req.requestId,'COMPLETED',value);
|
|
57
90
|
log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'OK'});
|
|
58
91
|
return {contract:CONTRACT,requestId:req.requestId,status:'OK',result:value};
|
|
59
92
|
}catch(e){
|
|
60
93
|
const code=e.code||'UNKNOWN_RESULT';
|
|
94
|
+
saveReceipt(req.requestId,code==='TIMEOUT'?'UNCONFIRMED':'FAILED',null,code);
|
|
61
95
|
log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'FAILED',errorCode:code});
|
|
62
96
|
return bad(req,code);
|
|
63
97
|
}
|
|
@@ -93,11 +127,17 @@ async function execute(req){
|
|
|
93
127
|
outcome:'OBSERVED',pid:null,evidence:'REGISTRY',error:null};
|
|
94
128
|
return adapterFor(t).inspect(t,ctx);
|
|
95
129
|
}
|
|
96
|
-
if(req.action==='start')
|
|
130
|
+
if(req.action==='start'){
|
|
131
|
+
const began=await adapterFor(t).launch(t,ctx);
|
|
132
|
+
if(t.id==='tunnel'&&began.after==='RUNNING')desiredTunnel.add(t.id);
|
|
133
|
+
return began;
|
|
134
|
+
}
|
|
135
|
+
if(t.id==='tunnel')desiredTunnel.delete(t.id);
|
|
97
136
|
if(req.action==='stop')return adapterFor(t).stop(t,ctx);
|
|
98
137
|
const ended=await adapterFor(t).stop(t,ctx);
|
|
99
138
|
if(ended.error)return {...ended,outcome:'RESTART_FAILED'};
|
|
100
139
|
const began=await adapterFor(t).launch(t,ctx);
|
|
140
|
+
if(t.id==='tunnel'&&began.after==='RUNNING')desiredTunnel.add(t.id);
|
|
101
141
|
return {...began,before:ended.before,outcome:began.error?'RESTART_FAILED':'RESTARTED'};
|
|
102
142
|
}).catch(e=>({toolId:t.id,enabled:t.enabled,before:'UNKNOWN',after:'UNKNOWN',
|
|
103
143
|
outcome:'ADAPTER_FAILED',pid:null,evidence:'UNHANDLED_ADAPTER_ERROR',
|
|
@@ -105,7 +145,10 @@ async function execute(req){
|
|
|
105
145
|
results.push(item);
|
|
106
146
|
}
|
|
107
147
|
const failures=results.filter(r=>r.error);
|
|
108
|
-
const status=results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':
|
|
148
|
+
const status=results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':
|
|
149
|
+
results.some(r=>r.after==='DEGRADED')?'DEGRADED':
|
|
150
|
+
results.some(r=>r.after==='STARTING')?'STARTING':
|
|
151
|
+
failures.length===0?'OK':failures.length===results.length?'FAILED':'PARTIAL';
|
|
109
152
|
const response={contract:CONTRACT,requestId:req.requestId,action:req.action,scope:req.scope,target:req.toolId||null,status,results,managerPid:process.pid};
|
|
110
153
|
log('control',{requestId:req.requestId,action:req.action,scope:req.scope,toolId:req.toolId||null,status,
|
|
111
154
|
results:results.map(r=>({toolId:r.toolId,after:r.after,outcome:r.outcome,errorCode:r.error?.code||null}))});
|
|
@@ -147,7 +190,7 @@ async function main(){
|
|
|
147
190
|
handled=true;
|
|
148
191
|
let req;try{req=JSON.parse(buf.slice(0,n));}catch{socket.end(JSON.stringify(bad({},'INVALID_ARGUMENT'))+'\n');return;}
|
|
149
192
|
const work=req?.action==='__ping'?
|
|
150
|
-
Promise.resolve({contract:CONTRACT,version:VERSION,registrySignature:catalog.map(t=>t.id+':'+t.enabled).join('|'),ownerNonce:nonce,managerPid:process.pid}):req?.action==='call'?executeCall(req):execute(req);
|
|
193
|
+
Promise.resolve({contract:CONTRACT,version:VERSION,packageVersion:PACKAGE_VERSION,registrySignature:catalog.map(t=>t.id+':'+t.enabled).join('|'),ownerNonce:nonce,managerPid:process.pid}):req?.action==='call'?executeCall(req):req?.action==='receipt'?Promise.resolve(receipt(req)):execute(req);
|
|
151
194
|
work.then(value=>socket.end(JSON.stringify(value)+'\n')).catch(err=>{
|
|
152
195
|
log('ipc-error',{code:err.code||'INTERNAL_ERROR'});
|
|
153
196
|
socket.end(JSON.stringify(bad(req,'INTERNAL_ERROR'))+'\n');
|