@tyhld/conductor 0.3.0 → 0.6.0
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 +301 -19
- package/dist/cli.js +143 -18
- package/dist/ear-routing.js +57 -0
- package/dist/ear.js +57 -0
- package/dist/env-file-perm.js +67 -0
- package/dist/nudge.js +172 -0
- package/dist/realtime-parse.js +116 -0
- package/dist/realtime.js +251 -0
- package/dist/relay-runner.js +65 -0
- package/dist/relay.js +832 -133
- package/dist/websocket-transport.js +66 -0
- package/launchd/ear-install.sh +96 -0
- package/package.json +30 -1
- package/sales-template/README.md +185 -25
- package/sales-template/install.sh +890 -0
- package/sales-template/launchd/install.sh +151 -0
- package/sales-template/settings.json +26 -97
- package/sales-template/setup.sh +754 -117
- package/sales-template/systemd/README.md +28 -4
- package/sales-template/systemd/install.sh +54 -5
- package/sales-template/systemd/paste-cache-prune-install.sh +75 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.service +25 -0
- package/sales-template/systemd/tyhld-paste-cache-prune.timer +19 -0
- package/sales-template/uninstall.sh +245 -0
- package/scripts/hooks/README.md +246 -0
- package/scripts/hooks/cc2_guard.py +145 -0
- package/scripts/hooks/codex-hooks.sample.json +58 -0
- package/scripts/hooks/hook_datalink.py +440 -0
- package/scripts/hooks/install-codex-hooks.sh +127 -0
- package/scripts/hooks/notification_hook.py +167 -0
- package/scripts/hooks/permission_request_hook.py +207 -0
- package/scripts/hooks/policy.py +759 -0
- package/scripts/hooks/settings.sample.json +142 -0
- package/scripts/hooks/stop_hook.py +275 -0
- package/scripts/hooks/summary_ja.py +155 -0
- package/scripts/hooks/test_hook_datalink.py +282 -0
- package/scripts/hooks/test_policy.py +1241 -0
- package/skills/conductor-craftsman/SKILL.md +40 -0
- package/systemd/conductor-ear.service +63 -0
- package/systemd/conductor@.service +62 -0
- package/systemd/ear-install.sh +131 -0
- package/systemd/guard-sync-install.sh +94 -0
- package/systemd/tyhld-guard-sync.service +28 -0
- package/systemd/tyhld-guard-sync.timer +25 -0
- package/sales-template/cc2_guard.py +0 -395
- package/sales-template/systemd/conductor@.service +0 -47
package/dist/cli.js
CHANGED
|
@@ -6,14 +6,19 @@
|
|
|
6
6
|
* POST {CONDUCTOR_URL}/api/conductor/report
|
|
7
7
|
* へ、起動直後に1回 + 以後 CONDUCTOR_INTERVAL_SEC 秒ごとに生存報告(heartbeat)を送る。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* 認可はテナントトークンの Bearer のみ(env CONDUCTOR_TOKEN。中央 TY Auth 発行の値)。
|
|
10
10
|
* 鍵はコードに書かず、必ず環境変数で渡す。
|
|
11
11
|
*/
|
|
12
|
-
import { execFileSync } from 'node:child_process';
|
|
13
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
-
import { createRelayState, relayTick, tmuxHasSession } from './relay.js';
|
|
16
|
+
import { createRelayState, parseAgentKind, relayTick, tmuxHasSession } from './relay.js';
|
|
17
|
+
import { createRelayRunner } from './relay-runner.js';
|
|
18
|
+
import { registerNudgeTarget, unregisterNudgeTarget, watchNudges, } from './nudge.js';
|
|
19
|
+
import { startEar } from './ear.js';
|
|
20
|
+
import { loadEarConfig } from './ear-routing.js';
|
|
21
|
+
import { ensureEnvFilePermissions } from './env-file-perm.js';
|
|
17
22
|
/** git コマンドを安全に実行し、失敗したら null を返す(git でない/未インストール等)。 */
|
|
18
23
|
function git(args) {
|
|
19
24
|
try {
|
|
@@ -83,12 +88,12 @@ function fail(message) {
|
|
|
83
88
|
/** 環境変数+引数から設定を組み立てる。必須が無ければここで終了する。 */
|
|
84
89
|
function loadConfig(args) {
|
|
85
90
|
const url = (process.env.CONDUCTOR_URL ?? '').trim();
|
|
86
|
-
const
|
|
91
|
+
const token = (process.env.CONDUCTOR_TOKEN ?? '').trim();
|
|
87
92
|
if (!url) {
|
|
88
93
|
fail('環境変数 CONDUCTOR_URL が未設定です(例: CONDUCTOR_URL=https://<your-conductor-host>)');
|
|
89
94
|
}
|
|
90
|
-
if (!
|
|
91
|
-
fail('環境変数
|
|
95
|
+
if (!token) {
|
|
96
|
+
fail('環境変数 CONDUCTOR_TOKEN が未設定です(中央 TY Auth が発行したテナントトークンを渡してください)');
|
|
92
97
|
}
|
|
93
98
|
const machine = (process.env.CONDUCTOR_MACHINE ?? '').trim() || os.hostname();
|
|
94
99
|
const status = (args.status ?? 'working').trim();
|
|
@@ -106,6 +111,8 @@ function loadConfig(args) {
|
|
|
106
111
|
}
|
|
107
112
|
intervalSec = parsed;
|
|
108
113
|
}
|
|
114
|
+
// ★職人の種類(ADR-021)。未設定・知らない値は 'claude'(後退させない)。
|
|
115
|
+
const agentKind = parseAgentKind(process.env.CONDUCTOR_AGENT_KIND);
|
|
109
116
|
// relay 設定(既定 off。off の間は既存の heartbeat 挙動を一切変えない)。
|
|
110
117
|
const relayEnabled = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_RELAY_ENABLED ?? '').trim());
|
|
111
118
|
let relayIntervalSec = 15;
|
|
@@ -117,9 +124,19 @@ function loadConfig(args) {
|
|
|
117
124
|
}
|
|
118
125
|
relayIntervalSec = parsed;
|
|
119
126
|
}
|
|
127
|
+
// 承認プロンプト待ちの間だけ使う短い間隔(既定3秒)。読み方は上の2つと同じ流儀。
|
|
128
|
+
let promptIntervalSec = 3;
|
|
129
|
+
const rawPromptInterval = (process.env.CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC ?? '').trim();
|
|
130
|
+
if (rawPromptInterval) {
|
|
131
|
+
const parsed = Number(rawPromptInterval);
|
|
132
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
133
|
+
fail(`CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC は正の数で指定してください(指定値: "${rawPromptInterval}")`);
|
|
134
|
+
}
|
|
135
|
+
promptIntervalSec = parsed;
|
|
136
|
+
}
|
|
120
137
|
return {
|
|
121
138
|
url: url.replace(/\/+$/, ''),
|
|
122
|
-
|
|
139
|
+
token,
|
|
123
140
|
machine,
|
|
124
141
|
site,
|
|
125
142
|
status,
|
|
@@ -127,6 +144,8 @@ function loadConfig(args) {
|
|
|
127
144
|
intervalSec,
|
|
128
145
|
relayEnabled,
|
|
129
146
|
relayIntervalSec,
|
|
147
|
+
promptIntervalSec,
|
|
148
|
+
agentKind,
|
|
130
149
|
};
|
|
131
150
|
}
|
|
132
151
|
/** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
|
|
@@ -144,7 +163,7 @@ async function sendReport(cfg) {
|
|
|
144
163
|
const res = await fetch(`${cfg.url}/api/conductor/report`, {
|
|
145
164
|
method: 'POST',
|
|
146
165
|
headers: {
|
|
147
|
-
Authorization: `Bearer ${cfg.
|
|
166
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
148
167
|
'Content-Type': 'application/json',
|
|
149
168
|
},
|
|
150
169
|
body: JSON.stringify(body),
|
|
@@ -175,14 +194,49 @@ async function start(args) {
|
|
|
175
194
|
}, cfg.intervalSec * 1000);
|
|
176
195
|
// ADR-029 relay。CONDUCTOR_RELAY_ENABLED=true のときだけ別インターバルで回す。
|
|
177
196
|
// heartbeat とは独立。off の間は何も起きない(既存挙動を壊さない)。
|
|
178
|
-
|
|
197
|
+
//
|
|
198
|
+
// ★適応ポーリング: 固定 setInterval ではなく「1回走らせて → 次回の間隔を状況で決める」自己再
|
|
199
|
+
// スケジュール(setTimeout チェーン)にする。承認プロンプトで職人が止まっている間だけ速く回し
|
|
200
|
+
// (promptIntervalSec・既定3秒)、それ以外は従来どおり(relayIntervalSec・既定15秒)。
|
|
201
|
+
// ねらい: 人を待たせている現場だけ速くする。待機中の現場まで速くすると、体感は変わらないのに
|
|
202
|
+
// 中央への問い合わせ(=Vercel の実行回数)だけが増えてしまう。
|
|
203
|
+
// ★relayTick を await するので、前回が終わってから次を積む=多重起動しない
|
|
204
|
+
// (relayTick 側の running ガードも従来どおり残っている=二重の防御)。
|
|
205
|
+
// ★ADR-066 便2c: この回し役(createRelayRunner)は「合図が来たら1周を前倒しする」入口も兼ねる。
|
|
206
|
+
// 合図を運ぶのは耳の常駐(conductor ear / PCに1本)から届くプロセス間の呼び鈴だけで、
|
|
207
|
+
// relay 自身は Realtime 接続を張らない(便2bで各relayに入れた耳は撤去した)。
|
|
208
|
+
// ポーリングの間隔はこれまでどおり(15秒/承認待ち3秒)。耳が死んでいる間の保険として残す。
|
|
209
|
+
let relayRunner = null;
|
|
210
|
+
let nudgeWatcher = null;
|
|
179
211
|
if (cfg.relayEnabled) {
|
|
180
212
|
console.log(`[relay] 有効: site=${cfg.site} interval=${cfg.relayIntervalSec}s ` +
|
|
213
|
+
`(承認待ちの間だけ ${cfg.promptIntervalSec}s に短縮)` +
|
|
181
214
|
`(tmuxセッション "${cfg.site}" がアイドルのときだけ送信します)`);
|
|
182
215
|
const relayState = createRelayState();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
216
|
+
relayRunner = createRelayRunner({
|
|
217
|
+
tick: () => relayTick(cfg, relayState),
|
|
218
|
+
// 承認プロンプト待ちの間だけ短い間隔。それ以外は従来どおり
|
|
219
|
+
// (★間隔は耳を付けても変えない=線が切れている間はこのポーリングがそのまま肩代わりする保険)。
|
|
220
|
+
nextDelayMs: () => (relayState.promptPending ? cfg.promptIntervalSec : cfg.relayIntervalSec) * 1000,
|
|
221
|
+
onError: (err) => {
|
|
222
|
+
// ループは絶対に止めない(1回の失敗で relay が死ぬと配達も承認も止まる)。
|
|
223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
224
|
+
console.log(`[relay] site=${cfg.site} tick で例外: ${msg}(次回に再試行)`);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
void relayRunner.run(); // 起動直後に1回。以後は自分で次を積む。
|
|
228
|
+
// ADR-066 便2c: 耳の常駐(conductor ear / この機に1本)からの呼び鈴を受ける。
|
|
229
|
+
// ・自分の合図ファイル(空ファイル)を用意する=「この現場のrelayが動いている」の名簿登録も兼ねる。
|
|
230
|
+
// ・呼び鈴が鳴ったらポーリング1周を前倒しするだけ。合図に中身は無く、指示は従来どおり取得APIから取る。
|
|
231
|
+
// ・呼び鈴が来なくても・耳が落ちていても、上のポーリングがそのまま動く(耳は加速装置であって依存先ではない)。
|
|
232
|
+
const registered = registerNudgeTarget(cfg.site);
|
|
233
|
+
if (registered !== null) {
|
|
234
|
+
console.log(`[relay] 合図の受け口: ${registered}(耳からの「今すぐ1周」を受けます)`);
|
|
235
|
+
}
|
|
236
|
+
nudgeWatcher = watchNudges(cfg.site, () => {
|
|
237
|
+
console.log(`[relay] site=${cfg.site} 耳から合図 → 指示の取得を前倒しします`);
|
|
238
|
+
void relayRunner?.run();
|
|
239
|
+
});
|
|
186
240
|
}
|
|
187
241
|
// Ctrl+C で綺麗に停止。
|
|
188
242
|
// ※ 中央APIは status を idle|working のみ受理し offline は受け付けない
|
|
@@ -190,14 +244,40 @@ async function start(args) {
|
|
|
190
244
|
// そのため終了時は余計な送信をせず、タイマーを止めて静かに終わる。
|
|
191
245
|
const stop = () => {
|
|
192
246
|
clearInterval(timer);
|
|
193
|
-
|
|
194
|
-
|
|
247
|
+
// 適応スケジューラは setTimeout チェーン。次回の予約を消し、走行中でも再スケジュールさせない。
|
|
248
|
+
relayRunner?.stop();
|
|
249
|
+
// 呼び鈴の見張りも畳み、名簿からも自分を外す(耳が空振りの呼び鈴を鳴らさないように)。
|
|
250
|
+
nudgeWatcher?.stop();
|
|
251
|
+
if (cfg.relayEnabled)
|
|
252
|
+
unregisterNudgeTarget(cfg.site);
|
|
195
253
|
console.log('\n[conductor] 停止しました。');
|
|
196
254
|
process.exit(0);
|
|
197
255
|
};
|
|
198
256
|
process.on('SIGINT', stop);
|
|
199
257
|
process.on('SIGTERM', stop);
|
|
200
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* `conductor ear`: この機に1本だけ動かす「耳」。合図を受けて各現場の relay へ呼び鈴を送る。
|
|
261
|
+
* 指示は取らず、職人にも触らない。落ちても各relayは15秒ポーリングで完全に動き続ける。
|
|
262
|
+
*/
|
|
263
|
+
function runEarCommand() {
|
|
264
|
+
let cfg;
|
|
265
|
+
try {
|
|
266
|
+
cfg = loadEarConfig();
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
270
|
+
}
|
|
271
|
+
const ear = startEar(cfg);
|
|
272
|
+
console.log('[ear] 停止するには Ctrl+C を押してください。');
|
|
273
|
+
const stop = () => {
|
|
274
|
+
ear.stop();
|
|
275
|
+
console.log('\n[ear] 停止しました。');
|
|
276
|
+
process.exit(0);
|
|
277
|
+
};
|
|
278
|
+
process.on('SIGINT', stop);
|
|
279
|
+
process.on('SIGTERM', stop);
|
|
280
|
+
}
|
|
201
281
|
/** package.json の version を返す(読めなければ 'unknown')。dist/cli.js からは ../package.json。 */
|
|
202
282
|
function readVersion() {
|
|
203
283
|
try {
|
|
@@ -212,16 +292,38 @@ function readVersion() {
|
|
|
212
292
|
function printVersion() {
|
|
213
293
|
console.log(readVersion());
|
|
214
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* `conductor setup <現場名> [フォルダ]` — 同梱のインストーラをそのまま実行する(ADR-018)。
|
|
297
|
+
*
|
|
298
|
+
* 【なぜ要るか】配布物の中に setup.sh は入っているが、お客さまは「それがどこにあるか」を
|
|
299
|
+
* 知らない。案内に `$(npm root -g)/@tyhld/conductor/sales-template/setup.sh` のような
|
|
300
|
+
* 道のりを書かせると、貼り間違いが起きるうえ、非エンジニアには意味が読めない。
|
|
301
|
+
* ★このコマンドがあれば「conductor setup <現場名>」の1行で済む。
|
|
302
|
+
* 【やること】自分(dist/cli.js)から見た相対位置のインストーラを、引数をそのまま渡して呼ぶ。
|
|
303
|
+
* ★中身の判断はすべて setup.sh 側。ここは場所を知っているだけの入口。
|
|
304
|
+
*/
|
|
305
|
+
function runSetupCommand(argv) {
|
|
306
|
+
const setup = fileURLToPath(new URL('../sales-template/setup.sh', import.meta.url));
|
|
307
|
+
if (!existsSync(setup)) {
|
|
308
|
+
fail(`インストーラが見つかりません: ${setup}\n` +
|
|
309
|
+
' 配布物が壊れている可能性があります。次の1行で入れ直してください:\n' +
|
|
310
|
+
' npm i -g @tyhld/conductor');
|
|
311
|
+
}
|
|
312
|
+
const r = spawnSync('bash', [setup, ...argv], { stdio: 'inherit' });
|
|
313
|
+
process.exit(r.status ?? 1);
|
|
314
|
+
}
|
|
215
315
|
function printUsage() {
|
|
216
316
|
console.log([
|
|
217
317
|
'@tyhld/conductor — 生存報告(heartbeat)常駐CLI',
|
|
218
318
|
'',
|
|
219
319
|
'使い方:',
|
|
220
|
-
' conductor start [-s <site>] [-t <idle|working>] [-a <lastAction>]',
|
|
320
|
+
' conductor start [-s <site>] [-t <idle|working>] [-a <lastAction>] 現場ごとの常駐(heartbeat+relay)',
|
|
321
|
+
' conductor ear この機に1本の「耳」(合図の受け取り役)',
|
|
322
|
+
' conductor setup <現場名> [フォルダ] 采配くんをこのPCに設置する(初回の1回だけ)',
|
|
221
323
|
'',
|
|
222
324
|
'必須の環境変数:',
|
|
223
325
|
' CONDUCTOR_URL 中央(devlog-tracker)のベースURL',
|
|
224
|
-
'
|
|
326
|
+
' CONDUCTOR_TOKEN テナントトークン(中央 TY Auth が発行した値)',
|
|
225
327
|
'',
|
|
226
328
|
'任意の環境変数:',
|
|
227
329
|
' CONDUCTOR_MACHINE PC識別名(既定: os.hostname())',
|
|
@@ -230,18 +332,41 @@ function printUsage() {
|
|
|
230
332
|
'relay(ADR-029 段階3 / 既定off。tmuxセッション名=site の現場が対象):',
|
|
231
333
|
' CONDUCTOR_RELAY_ENABLED true で relay 有効(既定: false)',
|
|
232
334
|
' CONDUCTOR_RELAY_INTERVAL_SEC relayポーリング間隔・秒(既定: 15)',
|
|
335
|
+
' CONDUCTOR_RELAY_PROMPT_INTERVAL_SEC 承認待ちの間だけ使う短い間隔・秒(既定: 3)',
|
|
336
|
+
'',
|
|
337
|
+
'耳(ADR-066 / この機に1本だけ動かす常駐。systemd の conductor-ear.service 推奨):',
|
|
338
|
+
' conductor ear は、指示が積まれた合図(Supabase Realtime)をPCにつき1本の接続で受け取り、',
|
|
339
|
+
' その現場の relay へ「今すぐ1周」だけを伝えます(合図に指示本文もトークンも載りません)。',
|
|
340
|
+
' 接続先・鍵・購読設定は中央のAPIから受け取るため、追加の環境変数はありません。',
|
|
341
|
+
' ★耳が動いていなくても、各relayは上記の間隔のポーリングでこれまでどおり完全に動きます。',
|
|
342
|
+
' CONDUCTOR_NUDGE_DIR 耳→relay の合図ファイル置き場(既定: $XDG_RUNTIME_DIR/conductor)',
|
|
233
343
|
'',
|
|
234
344
|
'例:',
|
|
235
345
|
' CONDUCTOR_URL=https://<your-conductor-host> \\',
|
|
236
|
-
'
|
|
346
|
+
' CONDUCTOR_TOKEN=*** conductor start',
|
|
347
|
+
' CONDUCTOR_URL=https://<your-conductor-host> \\',
|
|
348
|
+
' CONDUCTOR_TOKEN=*** conductor ear',
|
|
237
349
|
].join('\n'));
|
|
238
350
|
}
|
|
239
351
|
async function main() {
|
|
240
352
|
const args = parseArgs(process.argv.slice(2));
|
|
241
353
|
switch (args.command) {
|
|
242
354
|
case 'start':
|
|
355
|
+
// ★常駐に入る前に、接続設定(~/.conductor.env)の権限を 600 へ揃える。
|
|
356
|
+
// 設置のときの chmod だけでは塞がらない(実機で 644 になっていた)ので、
|
|
357
|
+
// 起動のたびに自分で直す。失敗しても止まらない(この後の未設定チェックに任せる)。
|
|
358
|
+
ensureEnvFilePermissions();
|
|
243
359
|
await start(args);
|
|
244
360
|
break;
|
|
361
|
+
case 'ear':
|
|
362
|
+
// 耳も同じ鍵を使う常駐なので、同じ揃えをかける。
|
|
363
|
+
ensureEnvFilePermissions();
|
|
364
|
+
runEarCommand();
|
|
365
|
+
break;
|
|
366
|
+
case 'setup':
|
|
367
|
+
// ★引数はそのまま setup.sh へ渡す(judgement は全部インストーラ側)。
|
|
368
|
+
runSetupCommand(process.argv.slice(3));
|
|
369
|
+
break;
|
|
245
370
|
case 'version':
|
|
246
371
|
case '-v':
|
|
247
372
|
case '--version':
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — ADR-066 便2c 耳の「判断」だけを集めたモジュール(外部I/Oなし=単体テスト可能)。
|
|
3
|
+
*
|
|
4
|
+
* 耳の仕事は突き詰めると3つの判断しかない:
|
|
5
|
+
* ① 受け取った合図を、どの現場の relay へ渡すか(渡さないか)
|
|
6
|
+
* ② トークン要求に添える現場名をどれにするか
|
|
7
|
+
* ③ 動くのに要る設定が揃っているか
|
|
8
|
+
* ここを純粋関数にしておけば、他社・他PC・止まっている現場への空振りが構造的に起きないことを
|
|
9
|
+
* テストで固定できる。実際の接続・ファイル操作は src/ear.ts 側にある。
|
|
10
|
+
*/
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
/**
|
|
13
|
+
* 合図をどこへ渡すかを決める純粋関数。
|
|
14
|
+
* - 宛先が名簿(このPCで relay が動いている現場)にある → その現場へ渡す。
|
|
15
|
+
* - 宛先がこのPCで動いていない現場 → 無視してログだけ残す(ADR-066 確定設計3)。
|
|
16
|
+
* - 宛先が読めない(null) → 動いている全現場へ渡す(安全側。受け手は取得APIを1回叩くだけで、
|
|
17
|
+
* queued が無ければ何も起きない=ポーリング1周の前倒しと同じで無害)。
|
|
18
|
+
* - 名簿が空(relay が1つも動いていない) → 渡す先が無いので無視。
|
|
19
|
+
*/
|
|
20
|
+
export function routeSignal(signalSite, runningSites) {
|
|
21
|
+
if (runningSites.length === 0) {
|
|
22
|
+
return { kind: 'ignore', reason: 'この機で動いている現場がありません' };
|
|
23
|
+
}
|
|
24
|
+
if (signalSite === null) {
|
|
25
|
+
return { kind: 'nudge', sites: [...runningSites] };
|
|
26
|
+
}
|
|
27
|
+
if (runningSites.includes(signalSite)) {
|
|
28
|
+
return { kind: 'nudge', sites: [signalSite] };
|
|
29
|
+
}
|
|
30
|
+
return { kind: 'ignore', reason: `現場 ${signalSite} はこの機で動いていません` };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* トークン要求に添える現場名を選ぶ純粋関数。
|
|
34
|
+
* 既存API(POST /api/conductor/realtime-token)は現場名を要るが、購読は machine 単位なので
|
|
35
|
+
* どの現場を添えても受け取る合図は同じ。実在する現場(=今このPCで動いている現場)から選ぶことで、
|
|
36
|
+
* 在りもしない現場名を送って中央に弾かれることを避ける(中央側の変更は要らない)。
|
|
37
|
+
* 1つも動いていなければ null=耳は張らずに待つ。
|
|
38
|
+
*/
|
|
39
|
+
export function pickSiteForToken(runningSites) {
|
|
40
|
+
return runningSites.length > 0 ? runningSites[0] : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 耳が動くのに要る設定を環境変数から組み立てる。鍵はコードに書かず env からのみ受け取る。
|
|
44
|
+
* 足りなければ例外=はっきり止める(黙って動かないのがいちばん困る)。
|
|
45
|
+
*/
|
|
46
|
+
export function loadEarConfig(env = process.env) {
|
|
47
|
+
const url = (env.CONDUCTOR_URL ?? '').trim();
|
|
48
|
+
const token = (env.CONDUCTOR_TOKEN ?? '').trim();
|
|
49
|
+
if (!url) {
|
|
50
|
+
throw new Error('環境変数 CONDUCTOR_URL が未設定です(例: CONDUCTOR_URL=https://<your-conductor-host>)');
|
|
51
|
+
}
|
|
52
|
+
if (!token) {
|
|
53
|
+
throw new Error('環境変数 CONDUCTOR_TOKEN が未設定です(中央 TY Auth が発行したテナントトークンを渡してください)');
|
|
54
|
+
}
|
|
55
|
+
const machine = (env.CONDUCTOR_MACHINE ?? '').trim() || os.hostname();
|
|
56
|
+
return { url: url.replace(/\/+$/, ''), token, machine };
|
|
57
|
+
}
|
package/dist/ear.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — ADR-066 便2c「耳」の常駐(PCに1本)。
|
|
3
|
+
*
|
|
4
|
+
* 【役割】この機に1本だけ Supabase Realtime を張り、指示の着信合図を受け取って、
|
|
5
|
+
* その現場の relay プロセスへ「今すぐ1周して」とだけ伝える。それ以上のことはしない。
|
|
6
|
+
* - 指示は取らない(取るのは relay の仕事=既存の取得API)。
|
|
7
|
+
* - 職人(tmux)には一切触らない。
|
|
8
|
+
* - 合図に本文・トークンを載せない(載せる場所を作らない=空ファイルの touch)。
|
|
9
|
+
*
|
|
10
|
+
* 【なぜ独立した常駐にしたか(便2bの根治)】
|
|
11
|
+
* 便2b では relay プロセスごとに Realtime を張っていた。接続本数が「PC数 × 現場数」で増え、
|
|
12
|
+
* 1000社×12ソフトなら1万2千本になる。耳を1本に集約すれば、本数はソフト数ではなく【PC数】で決まる。
|
|
13
|
+
*
|
|
14
|
+
* 【耳が落ちても何も壊れない】
|
|
15
|
+
* 耳は加速装置であって依存先ではない。耳が落ちている・合図が来ない・購読設定が無い・
|
|
16
|
+
* supabase-js が入っていない——どの場合も、各 relay は従来どおり15秒ポーリングで完全に動く。
|
|
17
|
+
* 耳がしているのは「その15秒を待たずに1周を前倒しさせる」ことだけ。
|
|
18
|
+
*
|
|
19
|
+
* 判断(振り分け・現場の選び方・設定の読み取り)は src/ear-routing.ts に分けてある(単体テスト用)。
|
|
20
|
+
*/
|
|
21
|
+
import { startRealtimeEar } from './realtime.js';
|
|
22
|
+
import { listRegisteredSites, sendNudge } from './nudge.js';
|
|
23
|
+
import { pickSiteForToken, routeSignal } from './ear-routing.js';
|
|
24
|
+
const log = (msg) => console.log(`[ear] ${new Date().toLocaleTimeString()} ${msg}`);
|
|
25
|
+
/**
|
|
26
|
+
* 耳を起動する(`conductor ear`)。この機に1本だけ動かす想定。
|
|
27
|
+
* ★2本動いても壊れない(合図が二重に届いても、受け手は取得APIを1回余計に叩くだけ)。
|
|
28
|
+
* とはいえ無駄なので、常駐は systemd の conductor-ear.service で1本に保つ。
|
|
29
|
+
*/
|
|
30
|
+
export function startEar(cfg) {
|
|
31
|
+
log(`開始: machine=${cfg.machine} url=${cfg.url}`);
|
|
32
|
+
log('この機に1本だけ合図の線を張り、各現場のrelayへ「今すぐ1周」を伝えます(指示は取りません)。');
|
|
33
|
+
return startRealtimeEar(cfg, {
|
|
34
|
+
// 既存APIの realtime-token は現場名を要る。今このPCで動いている現場から1つ選んで添える
|
|
35
|
+
//(購読は machine 単位なので、どの現場を添えても受け取る合図は同じ)。中央側の変更は不要。
|
|
36
|
+
siteForToken: () => pickSiteForToken(listRegisteredSites()),
|
|
37
|
+
onSignal: (signalSite) => {
|
|
38
|
+
const route = routeSignal(signalSite, listRegisteredSites());
|
|
39
|
+
if (route.kind === 'ignore') {
|
|
40
|
+
log(`合図(site=${signalSite ?? '不明'})は渡しません: ${route.reason}`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
for (const site of route.sites) {
|
|
44
|
+
const result = sendNudge(site);
|
|
45
|
+
if (result === 'sent') {
|
|
46
|
+
log(`合図 → 現場 ${site} のrelayへ「今すぐ1周」を伝えました`);
|
|
47
|
+
}
|
|
48
|
+
else if (result === 'no-target') {
|
|
49
|
+
log(`現場 ${site} のrelayが見当たりません(15秒ポーリングで拾われます)`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
log(`現場 ${site} への伝達に失敗しました(15秒ポーリングで拾われます)`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接続設定ファイル(~/.conductor.env)の権限を、常駐の起動時に 600 へ揃える。
|
|
3
|
+
*
|
|
4
|
+
* 【なぜ要るか(実測 2026-09-03・調査便 01c1d2d5)】
|
|
5
|
+
* `setup.sh` ④ はこのファイルを作るときに `chmod 600` している。それでも開発機では
|
|
6
|
+
* 実際に **644(同じ機体の別ユーザーからも読める)** になっていた。中身は
|
|
7
|
+
* `CONDUCTOR_URL` と `CONDUCTOR_TOKEN`=中央へ入る鍵そのものなので、読めてはいけない。
|
|
8
|
+
*
|
|
9
|
+
* 644 になる道は「作るとき」以外にいくらでもある:人が `cp` や `mv` で置き直した/
|
|
10
|
+
* エディタが書き戻した/バックアップから復元した/別のPCから持ってきた。
|
|
11
|
+
* つまり **作る瞬間に一度 chmod するだけでは塞がらない**(実際に塞がっていなかった)。
|
|
12
|
+
*
|
|
13
|
+
* 【根治の考え方】
|
|
14
|
+
* 設置のときに1回直すのではなく、**常駐が起動するたびに自分で確かめて直す**。
|
|
15
|
+
* 常駐は必ず起動するので、どんな経路で 644 になっても次の起動で 600 に戻る。
|
|
16
|
+
* ★お客さま機でも同じ穴が出るのを、手順書ではなく構造で塞ぐ(`setup.sh` は変えない)。
|
|
17
|
+
*
|
|
18
|
+
* 【安全側の作り】
|
|
19
|
+
* ここは常駐の入口なので、**何があっても例外を投げない**。読めない・直せないときは
|
|
20
|
+
* 1行警告して先へ進むだけ(設定が無ければ、この後の CONDUCTOR_URL 未設定チェックが
|
|
21
|
+
* 今までどおり止める)。権限を直せなかったことで常駐そのものを止めはしない。
|
|
22
|
+
*/
|
|
23
|
+
import { chmodSync, statSync } from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
/** 接続設定ファイルに求める権限(所有者だけが読み書き)。 */
|
|
27
|
+
export const ENV_FILE_MODE = 0o600;
|
|
28
|
+
/** 既定の接続設定ファイルの場所(systemd の EnvironmentFile=%h/.conductor.env と同じ)。 */
|
|
29
|
+
export function defaultEnvFilePath() {
|
|
30
|
+
return path.join(os.homedir(), '.conductor.env');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 接続設定ファイルの権限を 600 に揃える。
|
|
34
|
+
*
|
|
35
|
+
* @param file 対象のファイル。既定は `~/.conductor.env`。
|
|
36
|
+
* @param logFn 1行ログの出し先(テストから差し替えられるように引数にしてある)。
|
|
37
|
+
* @returns 何をしたか。★例外は投げない。
|
|
38
|
+
*/
|
|
39
|
+
export function ensureEnvFilePermissions(file = defaultEnvFilePath(), logFn = console.log) {
|
|
40
|
+
let mode;
|
|
41
|
+
try {
|
|
42
|
+
// ★lstat ではなく stat。近道(symlink)で置かれていても、実体の権限を直したい。
|
|
43
|
+
mode = statSync(file).mode & 0o777;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
const code = err?.code;
|
|
47
|
+
if (code === 'ENOENT')
|
|
48
|
+
return 'absent';
|
|
49
|
+
logFn(`[conductor] warn: 接続設定 ${file} の権限を見られませんでした(${code ?? err})`);
|
|
50
|
+
return 'failed';
|
|
51
|
+
}
|
|
52
|
+
if (mode === ENV_FILE_MODE)
|
|
53
|
+
return 'ok';
|
|
54
|
+
try {
|
|
55
|
+
chmodSync(file, ENV_FILE_MODE);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const code = err?.code;
|
|
59
|
+
logFn(`[conductor] warn: 接続設定 ${file} が ${mode.toString(8)} のままです` +
|
|
60
|
+
`(600 に直せませんでした: ${code ?? err})。中身は中央へ入る鍵なので、` +
|
|
61
|
+
'chmod 600 を人の手で打ってください');
|
|
62
|
+
return 'failed';
|
|
63
|
+
}
|
|
64
|
+
logFn(`[conductor] 接続設定 ${file} の権限を ${mode.toString(8)} から 600 へ直しました` +
|
|
65
|
+
'(中身は中央へ入る鍵なので、所有者以外に読ませない)');
|
|
66
|
+
return 'fixed';
|
|
67
|
+
}
|
package/dist/nudge.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @tyhld/conductor — ADR-066 便2c「耳 → relay」の合図線(プロセス間の軽い呼び鈴)。
|
|
3
|
+
*
|
|
4
|
+
* 【何のためか】
|
|
5
|
+
* 便2b では relay プロセスごとに Realtime 接続を張っていた。これだと接続本数が
|
|
6
|
+
* 「PC数 × そのPCの現場数」で増える(1000社×12ソフトなら1万2千本)。
|
|
7
|
+
* 便2c では Realtime を張るのは PC に1本の耳(conductor-ear)だけにし、耳から各現場の relay へは
|
|
8
|
+
* この合図線で「今すぐ1周して」とだけ伝える。
|
|
9
|
+
*
|
|
10
|
+
* 【方式=空ファイルの touch(inotify)】
|
|
11
|
+
* - 合図の置き場は1つのフォルダ。現場ごとに `<現場名>.nudge` という【空ファイル】を置く。
|
|
12
|
+
* - relay は起動時に自分のファイルを作り(=自己申告の名簿にもなる)、フォルダを見張る。
|
|
13
|
+
* - 耳は該当ファイルを空のまま書き直すだけ。relay の見張りが起きて tick を前倒しする。
|
|
14
|
+
*
|
|
15
|
+
* 【なぜこの方式か】
|
|
16
|
+
* ① 合図に中身が無い(常に空ファイル)=指示本文もトークンも構造的に載らない。
|
|
17
|
+
* ② 常駐の待ち受けポートを開けない・鍵も要らない。同じユーザーのプロセス同士なので権限も素直。
|
|
18
|
+
* ③ relay が居なければファイルが無い=耳は「この現場はこのPCで動いていない」と判断できる。
|
|
19
|
+
* ④ 見張りが張れなくても・耳が落ちていても、relay は15秒ポーリングで完全に動く(加速装置であって依存先ではない)。
|
|
20
|
+
*
|
|
21
|
+
* 置き場は XDG_RUNTIME_DIR(/run/user/<uid> 等・再起動で消えるtmpfs)を第一候補にする。
|
|
22
|
+
* 合図は「その瞬間だけ意味がある使い捨て」なので、PCを再起動したら残骸ごと消えるのが正しい。
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, watch, writeFileSync } from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** 合図ファイルの拡張子。この綴りのファイルだけを名簿とみなす。 */
|
|
28
|
+
export const NUDGE_SUFFIX = '.nudge';
|
|
29
|
+
/** 同じ合図が連続で届いたときに束ねる幅(ms)。inotify は1回の書き込みで複数回鳴ることがある。 */
|
|
30
|
+
export const NUDGE_DEBOUNCE_MS = 100;
|
|
31
|
+
const log = (msg) => console.log(`[nudge] ${new Date().toLocaleTimeString()} ${msg}`);
|
|
32
|
+
/**
|
|
33
|
+
* 合図の置き場を決める純粋関数(環境変数を引数で受け取る=テスト可能)。
|
|
34
|
+
* 1. CONDUCTOR_NUDGE_DIR … 明示指定(試験・特殊環境用)
|
|
35
|
+
* 2. XDG_RUNTIME_DIR/conductor … systemd ユーザーサービスなら必ずある。再起動で消えるのが望ましい。
|
|
36
|
+
* 3. <一時フォルダ>/conductor-<ユーザーid> … 上が無い環境の受け皿。他ユーザーと混ざらないよう id を付ける。
|
|
37
|
+
*/
|
|
38
|
+
export function resolveNudgeDir(env = process.env, tmpDir = os.tmpdir(), uid = String(typeof process.getuid === 'function' ? process.getuid() : 'user')) {
|
|
39
|
+
const explicit = (env.CONDUCTOR_NUDGE_DIR ?? '').trim();
|
|
40
|
+
if (explicit !== '')
|
|
41
|
+
return explicit;
|
|
42
|
+
const runtime = (env.XDG_RUNTIME_DIR ?? '').trim();
|
|
43
|
+
if (runtime !== '')
|
|
44
|
+
return path.join(runtime, 'conductor');
|
|
45
|
+
return path.join(tmpDir, `conductor-${uid}`);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 現場名から合図ファイル名を作る純粋関数。
|
|
49
|
+
* 現場名は tmux セッション名でもあるので普通は英数字だが、フォルダ区切りや `..` が紛れ込んでも
|
|
50
|
+
* 置き場の外を指さないよう、区切り文字だけは潰しておく(合図線が置き場の外に出ない保証)。
|
|
51
|
+
*/
|
|
52
|
+
export function nudgeFileName(site) {
|
|
53
|
+
const safe = site.replace(/[/\\]/g, '_').replace(/^\.+/, '_');
|
|
54
|
+
return `${safe}${NUDGE_SUFFIX}`;
|
|
55
|
+
}
|
|
56
|
+
/** 合図ファイル名から現場名を戻す純粋関数(名簿を読むときに使う)。合図ファイルでなければ null。 */
|
|
57
|
+
export function siteFromNudgeFileName(fileName) {
|
|
58
|
+
if (!fileName.endsWith(NUDGE_SUFFIX))
|
|
59
|
+
return null;
|
|
60
|
+
const site = fileName.slice(0, -NUDGE_SUFFIX.length);
|
|
61
|
+
return site === '' ? null : site;
|
|
62
|
+
}
|
|
63
|
+
/** 合図ファイルの絶対パス。 */
|
|
64
|
+
export function nudgePath(site, dir = resolveNudgeDir()) {
|
|
65
|
+
return path.join(dir, nudgeFileName(site));
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* この現場の relay が動いていることを名簿に載せる(=空の合図ファイルを用意する)。
|
|
69
|
+
* 起動のたびに呼んでよい(冪等)。作れなければ null を返すだけで、relay は従来どおり動き続ける。
|
|
70
|
+
*/
|
|
71
|
+
export function registerNudgeTarget(site, dir = resolveNudgeDir()) {
|
|
72
|
+
try {
|
|
73
|
+
mkdirSync(dir, { recursive: true });
|
|
74
|
+
const file = nudgePath(site, dir);
|
|
75
|
+
writeFileSync(file, ''); // 中身は常に空。合図に情報を載せない(載せる場所を作らない)。
|
|
76
|
+
return file;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
log(`合図の受け口を用意できません(${err instanceof Error ? err.message : String(err)})。15秒ポーリングで動きます`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 名簿から自分を外す(relay を綺麗に止めるとき)。
|
|
85
|
+
* ★外し損ねても害は無い(耳が空振りの呼び鈴を1回鳴らすだけ)。置き場は再起動で消える場所なので、
|
|
86
|
+
* 異常終了で残った名残も自然に消える。だから失敗は握り潰してよい。
|
|
87
|
+
*/
|
|
88
|
+
export function unregisterNudgeTarget(site, dir = resolveNudgeDir()) {
|
|
89
|
+
try {
|
|
90
|
+
rmSync(nudgePath(site, dir), { force: true });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* 残っても害が無いので黙って諦める */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 名簿に載っている現場(=このPCで relay が動いている現場)の一覧。読めなければ空配列。 */
|
|
97
|
+
export function listRegisteredSites(dir = resolveNudgeDir()) {
|
|
98
|
+
try {
|
|
99
|
+
return readdirSync(dir)
|
|
100
|
+
.map((f) => siteFromNudgeFileName(f))
|
|
101
|
+
.filter((s) => s !== null)
|
|
102
|
+
.sort();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return []; // 置き場がまだ無い=relay が1つも動いていない。
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* ある現場の relay へ「今すぐ1周して」を伝える(空ファイルを書き直すだけ)。
|
|
110
|
+
* 名簿に無い現場(relay が動いていない)へは送らない=耳が空振りの見張りを起こさない。
|
|
111
|
+
*/
|
|
112
|
+
export function sendNudge(site, dir = resolveNudgeDir()) {
|
|
113
|
+
const file = nudgePath(site, dir);
|
|
114
|
+
if (!existsSync(file))
|
|
115
|
+
return 'no-target';
|
|
116
|
+
try {
|
|
117
|
+
writeFileSync(file, ''); // 空のまま書き直す=inotify が鳴る。中身は運ばない。
|
|
118
|
+
return 'sent';
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return 'error';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* 自分の現場宛ての合図を見張る(relay 側)。
|
|
126
|
+
* - 見張るのは【フォルダ】。ファイル単体を見張ると、作り直されたときに見失う。
|
|
127
|
+
* - 連続した合図は NUDGE_DEBOUNCE_MS で束ねる(1回の書き込みで複数回鳴ることがあるため)。
|
|
128
|
+
* - 張れない環境(inotify が使えない等)では、ログを残して見張りを諦める。
|
|
129
|
+
* ★これは「遅くなる」だけで壊れない。指示は15秒ポーリングが従来どおり拾う。
|
|
130
|
+
*/
|
|
131
|
+
export function watchNudges(site, onNudge, dir = resolveNudgeDir()) {
|
|
132
|
+
const target = nudgeFileName(site);
|
|
133
|
+
let last = 0;
|
|
134
|
+
let watcher = null;
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
watcher = watch(dir, (_event, fileName) => {
|
|
138
|
+
if (fileName === null || fileName.toString() !== target)
|
|
139
|
+
return;
|
|
140
|
+
const t = Date.now();
|
|
141
|
+
if (t - last < NUDGE_DEBOUNCE_MS)
|
|
142
|
+
return;
|
|
143
|
+
last = t;
|
|
144
|
+
onNudge();
|
|
145
|
+
});
|
|
146
|
+
watcher.on('error', (err) => {
|
|
147
|
+
log(`合図の見張りが止まりました(${err.message})。15秒ポーリングで動き続けます`);
|
|
148
|
+
try {
|
|
149
|
+
watcher?.close();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* 片付けの失敗は無視 */
|
|
153
|
+
}
|
|
154
|
+
watcher = null;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
log(`合図の見張りを張れません(${err instanceof Error ? err.message : String(err)})。15秒ポーリングで動き続けます`);
|
|
159
|
+
watcher = null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
stop: () => {
|
|
163
|
+
try {
|
|
164
|
+
watcher?.close();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* 片付けの失敗は無視 */
|
|
168
|
+
}
|
|
169
|
+
watcher = null;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|