ai_co_discuss 0.3.39 → 0.3.41
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/.agents/skills/discuss-host/references/discussion.md +4 -0
- package/.agents/skills/discuss-host/references/free.md +8 -0
- package/.agents/skills/discuss-host/references/workflow-draft.md +38 -0
- package/README.md +139 -138
- package/backend/server.py +4 -2
- package/docs/runner.md +8 -0
- package/package.json +86 -86
- package/runner/adapters/antigravity_cli.mjs +324 -324
- package/runner/adapters/api.mjs +313 -299
- package/runner/adapters/claude.mjs +384 -313
- package/runner/discussion_launch.mjs +1437 -1351
- package/runner/discussion_runner.mjs +233 -230
- package/runner/free_runner.mjs +907 -890
- package/runner/remote_launch.mjs +4 -2
- package/runner/workflows.mjs +321 -0
- package/web/app.js +4261 -4194
- package/web/modern.css +1 -1
- package/web/modern.js +4415 -3586
|
@@ -1,230 +1,233 @@
|
|
|
1
|
-
/** Shared room configuration, local bindings and the unified request runner CLI. */
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { dirname, join, resolve } from 'node:path';
|
|
6
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
-
import { parseArgs } from 'node:util';
|
|
9
|
-
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
-
import { adapter_of, capabilities_of, validate_participant } from './adapters/index.mjs';
|
|
11
|
-
import { adapter_profile, same_session_family } from './adapters/profiles.mjs';
|
|
12
|
-
import { read_api_key } from './discussion_api.mjs';
|
|
13
|
-
import { access_levels, PROJECT_ACCESS, READ_RULE } from './expert_prompts.mjs';
|
|
14
|
-
import { validate_scheduler } from './scheduler.mjs';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
config
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
participant.
|
|
64
|
-
participant.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
1
|
+
/** Shared room configuration, local bindings and the unified request runner CLI. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
+
import { adapter_of, capabilities_of, validate_participant } from './adapters/index.mjs';
|
|
11
|
+
import { adapter_profile, same_session_family } from './adapters/profiles.mjs';
|
|
12
|
+
import { read_api_key } from './discussion_api.mjs';
|
|
13
|
+
import { access_levels, PROJECT_ACCESS, READ_RULE } from './expert_prompts.mjs';
|
|
14
|
+
import { validate_scheduler } from './scheduler.mjs';
|
|
15
|
+
import { bind_room_workflow } from './workflows.mjs';
|
|
16
|
+
|
|
17
|
+
const POLL_MS = 2000;
|
|
18
|
+
let rg_available;
|
|
19
|
+
/**
|
|
20
|
+
* Windows PowerShell mis-decodes UTF-8 files, so point the AI at rg when its commands can run it; free rooms and
|
|
21
|
+
* discussions share it. Codex CLI puts its own bundled rg on its commands' PATH (tested 0.163), even where this
|
|
22
|
+
* machine's PATH has none; other software gets the rule only when rg is installed here.
|
|
23
|
+
*/
|
|
24
|
+
export function read_rule(adapter) {
|
|
25
|
+
if (process.platform !== 'win32') return '';
|
|
26
|
+
rg_available ??= spawnSync('rg', ['--version'], { stdio: 'ignore', windowsHide: true }).status === 0;
|
|
27
|
+
return rg_available || capabilities_of(adapter).ripgrep ? READ_RULE : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
31
|
+
/** The shared prompt for rounds bridged without named follow-ups: experts answer and question each other. */
|
|
32
|
+
export function peer_round_question(round) {
|
|
33
|
+
return `第 ${round} 轮:先读取上一轮其他专家的最新回答,回应你不同意或需要补充的观点;被其他专家 @ 提问时优先回答,也可以 @ 某位专家提出问题。`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Explicit per-room configuration is the only source of software paths and capabilities. */
|
|
37
|
+
export function load_config(path) {
|
|
38
|
+
return validate_config(JSON.parse(readFileSync(path, 'utf8')), dirname(resolve(path)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keep service-managed answers outside all room credentials, logs and databases. */
|
|
42
|
+
export function service_drafts(home, directory) {
|
|
43
|
+
const key = createHash('sha256').update(resolve(directory)).digest('hex');
|
|
44
|
+
return join(home, 'drafts', key);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Validate a launch draft with the same rules as a registered room configuration. */
|
|
48
|
+
export function validate_config(input, base_directory) {
|
|
49
|
+
const config = structuredClone(input);
|
|
50
|
+
config.project = config.cli_project || config.project || REPO_ROOT;
|
|
51
|
+
const url = new URL(config.site);
|
|
52
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('站点必须是无路径和凭据的 HTTP(S) 根地址');
|
|
53
|
+
if (!/^[A-Za-z0-9_-]+$/.test(config.room ?? '') || typeof config.token !== 'string' || config.token.length < 32) throw new Error('缺少房间及本房间凭据');
|
|
54
|
+
if (!config.project || !existsSync(join(config.project, 'runner', 'discuss.py'))) throw new Error('cli_project 必须指向 AI CoDiscuss 项目;省略时默认使用本仓库');
|
|
55
|
+
// A free room of manual members runs here only for smart scheduling or a preset project directory, which need no local software.
|
|
56
|
+
if (!config.participants || (!Object.keys(config.participants).length && !(config.scheduler || config.cwd))) throw new Error('请配置本机参与软件');
|
|
57
|
+
for (const participant of Object.values(config.participants)) {
|
|
58
|
+
const api = adapter_profile(participant.adapter)?.kind === 'api';
|
|
59
|
+
if (api) {
|
|
60
|
+
read_api_key(config.api_home, participant);
|
|
61
|
+
for (const field of ['project_id', 'executable', 'effort']) delete participant[field];
|
|
62
|
+
}
|
|
63
|
+
participant.cwd = participant.cwd ?? config.cwd;
|
|
64
|
+
participant.project_name = participant.project_name ?? config.discussion_project;
|
|
65
|
+
participant.project_access = participant.project_access || config.project_access || 'read';
|
|
66
|
+
// `own` is set only when the service takes over a manual member: it keeps its own session's permissions.
|
|
67
|
+
if (![...PROJECT_ACCESS, 'own'].includes(participant.project_access)) throw new Error('project_access 只能为 read、write、auto 或 full');
|
|
68
|
+
if (participant.project_access !== 'own' && !access_levels(participant.adapter).includes(participant.project_access)) {
|
|
69
|
+
throw new Error(adapter_profile(participant.adapter)?.unsupported_hints?.[participant.project_access] || '该软件不支持此项目权限档位');
|
|
70
|
+
}
|
|
71
|
+
if (participant.cwd && !statSync(participant.cwd).isDirectory()) throw new Error('cwd 必须是已有本机目录');
|
|
72
|
+
if (!api) validate_participant(participant);
|
|
73
|
+
}
|
|
74
|
+
// Every room is a free-collaboration room; a configuration without member credentials predates it and is not run.
|
|
75
|
+
if (![undefined, 'free'].includes(config.mode)) throw new Error('房间模式只能为 free');
|
|
76
|
+
if (Object.keys(config.participants).some(name => typeof config.members?.[name]?.token !== 'string' || config.members[name].token.length < 32)) {
|
|
77
|
+
throw new Error('房间配置须为每位成员保存成员凭证;旧版配置请重新接入房间');
|
|
78
|
+
}
|
|
79
|
+
// A bound workflow only names a folder and which member plays which card; its content is read at each execution.
|
|
80
|
+
if (config.workflow) config.workflow = bind_room_workflow({}, config.workflow).workflow;
|
|
81
|
+
// Smart scheduling is optional; without it every decision stays with the host.
|
|
82
|
+
if (config.scheduler) config.scheduler = validate_scheduler(config.api_home, config.scheduler);
|
|
83
|
+
return { ...config, mode: 'free', site: url.origin, project: resolve(config.project), directory: resolve(config.data_directory || join(base_directory, `discussion-${config.room}`)) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Send exactly one bounded website request; never follow redirects with a capability. */
|
|
87
|
+
export async function api(config, tail, body, token = config.token) {
|
|
88
|
+
const response = await fetch(`${config.site}/api/rooms/${config.room}${tail}`, {
|
|
89
|
+
method: body === undefined ? 'GET' : 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
|
|
90
|
+
headers: { 'Content-Type': 'application/json', 'X-Discussion-Token': token },
|
|
91
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
92
|
+
});
|
|
93
|
+
const result = await response.json();
|
|
94
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${result.error || '请求失败'}`);
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Persistent software bindings and room metadata. */
|
|
99
|
+
export class DiscussionStore {
|
|
100
|
+
constructor(directory) {
|
|
101
|
+
mkdirSync(directory, { recursive: true });
|
|
102
|
+
this.db = new DatabaseSync(join(directory, 'runtime.sqlite3'));
|
|
103
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
|
|
104
|
+
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);
|
|
105
|
+
CREATE TABLE IF NOT EXISTS bindings(participant TEXT PRIMARY KEY,session_id TEXT NOT NULL);`);
|
|
106
|
+
}
|
|
107
|
+
/** Keep local bindings in their original room. */
|
|
108
|
+
scope(config) {
|
|
109
|
+
const scope = `${config.site}/${config.room}`;
|
|
110
|
+
const previous = this.db.prepare("SELECT value FROM metadata WHERE key='scope'").get()?.value;
|
|
111
|
+
if (previous && previous !== scope) throw new Error('数据目录已绑定另一站点或房间;请选择独立目录');
|
|
112
|
+
this.db.prepare("INSERT INTO metadata VALUES ('scope',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(scope);
|
|
113
|
+
}
|
|
114
|
+
/** Do not silently reuse a conversation after its software project or cwd changes. */
|
|
115
|
+
context(config, allow_codex_switch = false) {
|
|
116
|
+
const entries = Object.entries(config.participants).sort(([left], [right]) => left.localeCompare(right))
|
|
117
|
+
.map(([name, item]) => [name, item.adapter, item.project_id || '', item.project_name || '',
|
|
118
|
+
item.cwd ? resolve(item.cwd) : '', item.workdir ? resolve(item.workdir) : '', item.session_id || '']);
|
|
119
|
+
const context = JSON.stringify(entries);
|
|
120
|
+
const previous = this.db.prepare("SELECT value FROM metadata WHERE key='context'").get()?.value;
|
|
121
|
+
if (previous === context) return;
|
|
122
|
+
if (previous && previous !== context) {
|
|
123
|
+
const before = JSON.parse(previous);
|
|
124
|
+
const allowed = allow_codex_switch && before.length === entries.length && before.every((item, index) => {
|
|
125
|
+
const next = entries[index];
|
|
126
|
+
if (JSON.stringify(item) === JSON.stringify(next)) return true;
|
|
127
|
+
const session = this.binding(item[0]).session_id || item[6];
|
|
128
|
+
return item[0] === next[0] && same_session_family(item[1], next[1])
|
|
129
|
+
&& session && next[6] === session && (!item[6] || item[6] === session)
|
|
130
|
+
&& JSON.stringify(item.slice(2, 6)) === JSON.stringify(next.slice(2, 6));
|
|
131
|
+
});
|
|
132
|
+
// Experts invited during a run only add entries; every existing binding must stay as it was.
|
|
133
|
+
const added = before.every(item => entries.some(next => JSON.stringify(next) === JSON.stringify(item)));
|
|
134
|
+
if (!allowed && !added) throw new Error('已绑定会话的项目、cwd 或身份配置发生变化;请暂停并使用独立运行目录重新接入');
|
|
135
|
+
}
|
|
136
|
+
this.db.prepare("INSERT INTO metadata VALUES ('context',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(context);
|
|
137
|
+
}
|
|
138
|
+
/** Resolve actual session bindings without reading unrelated conversations. */
|
|
139
|
+
binding(participant) { return this.db.prepare('SELECT session_id FROM bindings WHERE participant=?').get(participant) || {}; }
|
|
140
|
+
/** Commit a returned session before later notifications can use it. */
|
|
141
|
+
bind(participant, session_id) { this.db.prepare('INSERT INTO bindings VALUES (?,?) ON CONFLICT(participant) DO UPDATE SET session_id=excluded.session_id').run(participant, session_id); }
|
|
142
|
+
/** Release this instance's database connection. */
|
|
143
|
+
close() { this.db.close(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Hold one PID lock per runtime directory, without taking over a live owner. */
|
|
147
|
+
export function acquire_lock(directory) {
|
|
148
|
+
mkdirSync(directory, { recursive: true });
|
|
149
|
+
const path = join(directory, 'runner.lock');
|
|
150
|
+
if (existsSync(path)) {
|
|
151
|
+
const pid = Number(readFileSync(path, 'utf8'));
|
|
152
|
+
let alive = true;
|
|
153
|
+
try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
|
|
154
|
+
if (alive) throw new Error('此目录已有通知程序运行;不重复启动');
|
|
155
|
+
unlinkSync(path);
|
|
156
|
+
}
|
|
157
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
158
|
+
return () => { if (existsSync(path) && readFileSync(path, 'utf8') === String(process.pid)) unlinkSync(path); };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Pause the active discussion before its local consumer exits. */
|
|
162
|
+
export async function pause_if_running(config) {
|
|
163
|
+
const latest = await api(config, '/discussion');
|
|
164
|
+
if (latest.session?.status === 'active') await api(config, '/discussion', { action: 'pause', revision: latest.revision });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Start or resume the room's discussion with a freshly read control revision. */
|
|
168
|
+
async function start_discussion(config, minutes_value) {
|
|
169
|
+
const room = await api(config, '');
|
|
170
|
+
const current = await api(config, '/discussion');
|
|
171
|
+
if (current.session?.status === 'active') return;
|
|
172
|
+
const minutes = Number(minutes_value ?? current.session?.limits.max_minutes ?? room.limits?.max_minutes ?? 0);
|
|
173
|
+
if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) throw new Error('总时长或 --minutes 须在 [0,1440],0 表示不限时长');
|
|
174
|
+
if (!config.members?.[room.host]?.token) throw new Error('缺少主持人成员凭据;请重新接入房间后开启讨论');
|
|
175
|
+
if (current.session) {
|
|
176
|
+
if (minutes_value !== undefined) await api(config, '/limits', { max_minutes: minutes, revision: current.revision });
|
|
177
|
+
const latest = await api(config, '/discussion');
|
|
178
|
+
return api(config, '/discussion', { action: 'resume', revision: latest.revision });
|
|
179
|
+
}
|
|
180
|
+
return api(config, '/discussion', { action: 'start', revision: current.revision, goal: room.goal,
|
|
181
|
+
named_followup: Boolean(room.named_followup), limits: { max_minutes: minutes,
|
|
182
|
+
max_rounds: room.limits?.max_rounds ?? 0, execution_minutes: room.limits?.execution_minutes ?? 0 } });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Run an explicitly configured room; install and status never start a discussion. */
|
|
186
|
+
export async function main(args = process.argv.slice(2)) {
|
|
187
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
188
|
+
config: { type: 'string' }, once: { type: 'boolean' }, minutes: { type: 'string' },
|
|
189
|
+
} });
|
|
190
|
+
if (!values.config || !['watch', 'start', 'status', 'pause', 'setup-sidecar'].includes(positionals[0])) {
|
|
191
|
+
throw new Error('用法: discussion_runner.mjs watch|start|status|pause|setup-sidecar --config 私有配置.json [--minutes 60] [--once]');
|
|
192
|
+
}
|
|
193
|
+
const config = load_config(values.config);
|
|
194
|
+
if (positionals[0] === 'setup-sidecar') {
|
|
195
|
+
for (const participant of Object.values(config.participants)) {
|
|
196
|
+
const setup = adapter_of(participant.adapter)?.setup;
|
|
197
|
+
if (setup) process.stdout.write(JSON.stringify(setup(participant)) + '\n');
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const run = await api(config, '/discussion');
|
|
202
|
+
if (positionals[0] === 'status') { process.stdout.write(JSON.stringify(run) + '\n'); return; }
|
|
203
|
+
if (positionals[0] === 'pause') { await api(config, '/discussion', { action: 'pause', revision: run.revision }); return; }
|
|
204
|
+
const release = acquire_lock(config.directory);
|
|
205
|
+
const store = new DiscussionStore(config.directory);
|
|
206
|
+
const { FreeRoomRunner } = await import('./free_runner.mjs');
|
|
207
|
+
const runner = new FreeRoomRunner(config, store);
|
|
208
|
+
let stopped = false;
|
|
209
|
+
const stop = () => { stopped = true; };
|
|
210
|
+
process.once('SIGINT', stop);
|
|
211
|
+
process.once('SIGTERM', stop);
|
|
212
|
+
try {
|
|
213
|
+
store.context(config);
|
|
214
|
+
store.scope(config);
|
|
215
|
+
if (positionals[0] === 'start') await start_discussion(config, values.minutes);
|
|
216
|
+
do {
|
|
217
|
+
try { if (await runner.tick() === false) break; }
|
|
218
|
+
catch (error) { process.stderr.write(JSON.stringify({ error: error.message }) + '\n'); if (values.once) throw error; }
|
|
219
|
+
if (!values.once && !stopped) await sleep(POLL_MS);
|
|
220
|
+
} while (!values.once && !stopped);
|
|
221
|
+
} finally {
|
|
222
|
+
if (stopped) {
|
|
223
|
+
try { await pause_if_running(config); }
|
|
224
|
+
catch (error) { process.stderr.write(`停止回执未确认;租约到期后拒绝写入:${error.message}\n`); }
|
|
225
|
+
}
|
|
226
|
+
runner.stop_children(); store.close(); release();
|
|
227
|
+
process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
|
232
|
+
main().catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
233
|
+
}
|