@asc-agent/runtime 0.3.1 → 0.4.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 +1 -1
- package/dist/adapters/claude-code/install.d.ts +7 -0
- package/dist/adapters/claude-code/install.js +114 -38
- package/dist/adapters/claude-code/probe.js +6 -1
- package/dist/adapters/claude-code/session-start.d.ts +20 -0
- package/dist/adapters/claude-code/session-start.js +111 -0
- package/dist/adapters/jam/adapter.d.ts +46 -9
- package/dist/adapters/jam/adapter.js +88 -22
- package/dist/adapters/jam/mcp-client.js +5 -1
- package/dist/adapters/jam/setup.d.ts +62 -0
- package/dist/adapters/jam/setup.js +85 -0
- package/dist/adapters/service/launchd.d.ts +12 -0
- package/dist/adapters/service/launchd.js +80 -0
- package/dist/adapters/service/schtasks.d.ts +16 -0
- package/dist/adapters/service/schtasks.js +67 -0
- package/dist/adapters/service/systemd-user.d.ts +15 -0
- package/dist/adapters/service/systemd-user.js +95 -0
- package/dist/cli/asc.js +886 -147
- package/dist/composition/registry.js +11 -3
- package/dist/composition/runtime.d.ts +33 -0
- package/dist/composition/runtime.js +74 -0
- package/dist/core/attach/setup-plan.d.ts +73 -1
- package/dist/core/attach/setup-plan.js +56 -3
- package/dist/core/distribution/external-command.d.ts +22 -0
- package/dist/core/distribution/external-command.js +79 -0
- package/dist/core/distribution/persistent-runtime.d.ts +73 -0
- package/dist/core/distribution/persistent-runtime.js +49 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/operator/progress.js +3 -1
- package/dist/core/runtime/background.d.ts +104 -0
- package/dist/core/runtime/background.js +225 -0
- package/dist/core/runtime/front.d.ts +48 -0
- package/dist/core/runtime/front.js +34 -0
- package/dist/core/runtime/session.js +6 -1
- package/dist/core/runtime/workspaces.d.ts +39 -0
- package/dist/core/runtime/workspaces.js +64 -0
- package/dist/core/workspace/resolve.d.ts +36 -0
- package/dist/core/workspace/resolve.js +124 -3
- package/dist/ports/adapter.d.ts +12 -0
- package/dist/schemas/profile.d.ts +6 -6
- package/package.json +1 -1
package/dist/cli/asc.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// 출력만 있고 판단은 한 줄도 없다.
|
|
7
7
|
//
|
|
8
8
|
// 읽기 전용이다. 결정 제출은 사람의 명시적 의사표현을 받는 별도 경로로 나간다 (B-06).
|
|
9
|
-
import { execFile, spawnSync } from 'node:child_process';
|
|
9
|
+
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
10
10
|
import { parseArgs, promisify } from 'node:util';
|
|
11
11
|
import { existsSync, readdirSync, realpathSync } from 'node:fs';
|
|
12
12
|
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { homedir, userInfo } from 'node:os';
|
|
14
|
+
import { homedir, hostname, userInfo } from 'node:os';
|
|
15
15
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
16
|
import { MINIMUM_NODE_MAJOR, checkNodeRuntime, reexecWithCandidate, } from "../core/distribution/node-runtime.js";
|
|
17
17
|
import { RELEASE_VERSION } from "../core/distribution/release.js";
|
|
@@ -32,7 +32,7 @@ import { AdoptError, buildAdoptedProfile } from "../core/attach/adopt.js";
|
|
|
32
32
|
import { locatorsOf, lookupLocator, readIndex, register, writeIndex } from "../core/workspace/index-store.js";
|
|
33
33
|
import { adoptionLine, judgeAdoption, migrate } from "../core/workspace/migrate.js";
|
|
34
34
|
import { newWorkspaceId, normalizeRemote, recoverCandidates, recoverLines } from "../core/workspace/identity.js";
|
|
35
|
-
import { resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
|
|
35
|
+
import { gitWorktrees, resolveWorkspace, resolutionLine } from "../core/workspace/resolve.js";
|
|
36
36
|
import { assessSetup, renderSetup } from "../core/attach/setup.js";
|
|
37
37
|
import { withIdentity } from "../core/attach/init.js";
|
|
38
38
|
import { applySetupPlan, computeSetupPlan, renderSetupPlan, } from "../core/attach/setup-plan.js";
|
|
@@ -49,11 +49,12 @@ import { deriveSessionContractDraft } from "../core/operator/derive-draft.js";
|
|
|
49
49
|
import { LocalRepoAdapter } from "../adapters/local/repo.js";
|
|
50
50
|
import { GitHubAdapter } from "../adapters/github/adapter.js";
|
|
51
51
|
import { GitLabAdapter } from "../adapters/gitlab/adapter.js";
|
|
52
|
-
import { JamAdapter } from "../adapters/jam/adapter.js";
|
|
52
|
+
import { JamAdapter, resolveJamCommand } from "../adapters/jam/adapter.js";
|
|
53
|
+
import { healJam, healLine } from "../adapters/jam/setup.js";
|
|
53
54
|
import { statusIndicatesDone } from "../adapters/jam/ports.js";
|
|
54
55
|
import { ProgressService } from "../core/operator/progress.js";
|
|
55
56
|
import { composeBindings, defaultAdapters } from "../composition/registry.js";
|
|
56
|
-
import { buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
|
|
57
|
+
import { buildObservationChannels, buildRuntimePorts, closeToolClients, rolesFor } from "../composition/runtime.js";
|
|
57
58
|
import { proposeBindings } from "../composition/propose.js";
|
|
58
59
|
import { buildEventObservation } from "../composition/observe.js";
|
|
59
60
|
import { availableProfiles, planBootstrap, renderPlan } from "../core/attach/bootstrap.js";
|
|
@@ -64,15 +65,22 @@ import { preflight } from "../core/operator/preflight.js";
|
|
|
64
65
|
import { DraftProvenance, issueArgs, planSessionContract, } from "../core/operator/contract-draft.js";
|
|
65
66
|
import { lookupAuthority } from "../core/policy/ownership.js";
|
|
66
67
|
import { renderProgress } from "../core/operator/render.js";
|
|
67
|
-
import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, } from "../core/runtime/audit.js";
|
|
68
|
+
import { AuditLedger, decisionLines, delegationLine, executionLines, reclaimLine, validationLines, DecisionClass, } from "../core/runtime/audit.js";
|
|
68
69
|
import { ClosureLedger } from "../core/runtime/closure.js";
|
|
69
70
|
import { Orchestrator, renderTick } from "../core/runtime/orchestrator.js";
|
|
71
|
+
import { LAST_RUN_KEY, RuntimeLease, fileScope, readBackground, renderBackground, staleAfter, } from "../core/runtime/background.js";
|
|
72
|
+
import { dueWorkspaces, renderWorkspaces, viewWorkspaces, } from "../core/runtime/workspaces.js";
|
|
73
|
+
import { persistentRuntimeLine, planPersistentRuntime, } from "../core/distribution/persistent-runtime.js";
|
|
74
|
+
import { launchdAdapter } from "../adapters/service/launchd.js";
|
|
75
|
+
import { schtasksAdapter } from "../adapters/service/schtasks.js";
|
|
76
|
+
import { serviceAdapterFor, systemdUserAdapter } from "../adapters/service/systemd-user.js";
|
|
70
77
|
import { QueryLedger } from "../core/runtime/query.js";
|
|
71
78
|
import { ObservationLedger } from "../core/monitor/observation.js";
|
|
72
79
|
import { DeliveryLedger, deliver, planDigest } from "../core/presentation/digest.js";
|
|
73
80
|
import { LocalPresentation } from "../adapters/local/presentation.js";
|
|
74
81
|
import { collectSessions, renderCollect } from "../core/runtime/controller.js";
|
|
75
|
-
import { renderFront, restoreFront } from "../core/runtime/front.js";
|
|
82
|
+
import { frontOpeningLines, openFront, renderFront, restoreFront } from "../core/runtime/front.js";
|
|
83
|
+
import { sessionStartPayload } from "../adapters/claude-code/session-start.js";
|
|
76
84
|
import { EscalationLedger, escalationLines } from "../core/runtime/escalation.js";
|
|
77
85
|
import { deriveExecutionState, executionLine } from "../core/runtime/execution-state.js";
|
|
78
86
|
import { buildFinalReport, renderFinalReport } from "../core/runtime/report.js";
|
|
@@ -109,13 +117,19 @@ const USAGE = `asc — Agent Session Control
|
|
|
109
117
|
asc profile adopt [--id <name>] [--json] # make a profile for this repository
|
|
110
118
|
asc profile resolve --profile <id> [--preset <id>] [--install <path>] [--write]
|
|
111
119
|
|
|
112
|
-
asc runtime start [--
|
|
113
|
-
[--census-min <n>] [--digest-min <n>]
|
|
114
|
-
|
|
115
|
-
asc runtime
|
|
120
|
+
asc runtime start [--detach] [--interval-min <n>] [--delta-min <n>]
|
|
121
|
+
[--reconcile-min <n>] [--census-min <n>] [--digest-min <n>]
|
|
122
|
+
# --detach: keep observing after this terminal closes
|
|
123
|
+
asc runtime tick [--all] # --all: every workspace this machine knows
|
|
124
|
+
asc runtime list [--json] # every workspace, without visiting each one
|
|
125
|
+
asc runtime service [status] [--json] # the machine's persistent registration
|
|
126
|
+
asc runtime service install|uninstall [--interval-min <n>]
|
|
127
|
+
asc runtime stop # ask the background runtime to finish its pass
|
|
128
|
+
asc runtime status [--json] # which build is in use, and whether it observes
|
|
116
129
|
asc runtime use package
|
|
117
130
|
asc runtime use development <checkout> # run a built checkout instead
|
|
118
131
|
asc front [status] [--json]
|
|
132
|
+
asc front open [--json] # a host session opened here — what is waiting
|
|
119
133
|
asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
|
|
120
134
|
--evidence <ref>... [--blocked-scope <path>...] [--previous <ESC-ID> --why <t>]
|
|
121
135
|
asc escalate list
|
|
@@ -159,7 +173,7 @@ const USAGE = `asc — Agent Session Control
|
|
|
159
173
|
asc session pause <ID> --position <text> --next <text> [--done <task>...]
|
|
160
174
|
asc session resume <ID>
|
|
161
175
|
asc session done <ID> --verified <text> --next <text> [--done <task>...]
|
|
162
|
-
[--changed <path>...] [--unresolved <text>...]
|
|
176
|
+
[--changed <path>...] [--unresolved <text>...] [--physical <id>]
|
|
163
177
|
asc session list
|
|
164
178
|
|
|
165
179
|
asc controller collect
|
|
@@ -232,15 +246,58 @@ async function discoverRoot(start, explicitRoot) {
|
|
|
232
246
|
return resolution.kind === 'UNRESOLVED' ? null : resolution.root;
|
|
233
247
|
}
|
|
234
248
|
async function resolveRoot(start, explicitRoot) {
|
|
235
|
-
|
|
249
|
+
const home = ascHome();
|
|
250
|
+
const index = await readIndex(home);
|
|
251
|
+
const resolution = await resolveWorkspace({
|
|
236
252
|
cwd: start,
|
|
237
253
|
...(explicitRoot ? { explicitRoot } : {}),
|
|
238
|
-
index
|
|
254
|
+
index,
|
|
239
255
|
// 홈의 `~/.asc` 는 user runtime이지 프로젝트 상태가 아니다 — 그 위로 올라가지 않는다
|
|
240
256
|
stopAt: homedir(),
|
|
257
|
+
// 등록이 빗나갔을 때만 Git에게 구조를 묻는다 (C-11 §1.3).
|
|
258
|
+
worktrees: gitWorktrees,
|
|
241
259
|
});
|
|
260
|
+
// 같은 저장소의 다른 checkout으로 풀렸으면 **이 경로를 등록해 둔다.** 그래야 다음부터는
|
|
261
|
+
// 예전과 같은 index 조회 하나로 끝나고, guard hook도 이 checkout을 관리 대상으로 본다.
|
|
262
|
+
if (resolution.kind === 'LINKED_WORKTREE')
|
|
263
|
+
await rememberWorktree(home, index, resolution);
|
|
264
|
+
return resolution;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* 이번에 구조로 풀린 checkout을 index에 적는다. **workspace를 새로 만들지 않는다** —
|
|
268
|
+
* 같은 논리 workspace의 execution instance가 하나 늘었을 뿐이다 (C-11 §1.3).
|
|
269
|
+
*
|
|
270
|
+
* 실패해도 이번 판정을 무르지 않는다. 다음 호출이 Git을 한 번 더 부르는 것으로 끝난다 —
|
|
271
|
+
* 쓰기 하나가 안 됐다고 붙어 있는 workspace를 못 쓰게 만드는 쪽이 나쁘다.
|
|
272
|
+
*/
|
|
273
|
+
async function rememberWorktree(home, index, resolution) {
|
|
274
|
+
const now = new Date().toISOString();
|
|
275
|
+
try {
|
|
276
|
+
await writeIndex(home, register(index, {
|
|
277
|
+
workspaceId: resolution.workspaceId,
|
|
278
|
+
root: resolution.root,
|
|
279
|
+
locator: {
|
|
280
|
+
path: resolution.locator,
|
|
281
|
+
kind: resolution.kindOfLocator,
|
|
282
|
+
platform: process.platform,
|
|
283
|
+
observedAt: now,
|
|
284
|
+
},
|
|
285
|
+
now,
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// 등록만 못 했을 뿐이다. 판정은 그대로 선다.
|
|
290
|
+
}
|
|
242
291
|
}
|
|
243
292
|
const execFileAsync = promisify(execFile);
|
|
293
|
+
/**
|
|
294
|
+
* Claude Host 설치 경로 + **이 CLI의 위치**.
|
|
295
|
+
*
|
|
296
|
+
* SessionStart hook은 상태를 물어볼 곳이 필요하고, 그곳은 지금 도는 이 실행파일이다.
|
|
297
|
+
* 그 CLI가 다시 선택된 build로 넘기므로(`runtime use development` 포함) hook이 build를
|
|
298
|
+
* 고르는 일은 없다.
|
|
299
|
+
*/
|
|
300
|
+
const hostPaths = () => ({ ...defaultPaths(), entry: fileURLToPath(import.meta.url) });
|
|
244
301
|
/**
|
|
245
302
|
* 이 binding이 가리키는 주소. 자체 호스팅은 발견 단계가 이미 알아냈으므로 같은 값을 쓴다 —
|
|
246
303
|
* 여기서 다시 추측하면 두 곳이 다른 주소를 말하게 된다.
|
|
@@ -294,17 +351,60 @@ const ACTIVE_SOURCE_KEY = 'active-source';
|
|
|
294
351
|
* digest처럼 조립을 하지 않는 명령도 같은 자리를 읽어야 하는데, 그걸 알아내려고 discovery와
|
|
295
352
|
* probe를 다시 돌리면 화면 하나 그리자고 외부를 친다. 그래서 monitor가 돌 때 적어 둔다.
|
|
296
353
|
*/
|
|
297
|
-
async function
|
|
298
|
-
await store.scope('monitor').set(ACTIVE_SOURCE_KEY,
|
|
354
|
+
async function rememberMonitorSources(store, sourceIds) {
|
|
355
|
+
await store.scope('monitor').set(ACTIVE_SOURCE_KEY, JSON.stringify([...sourceIds]));
|
|
299
356
|
}
|
|
300
357
|
/**
|
|
301
|
-
* 마지막으로 붙었던
|
|
302
|
-
*
|
|
358
|
+
* 마지막으로 붙었던 통로들.
|
|
359
|
+
*
|
|
360
|
+
* 예전에는 문자열 하나였다 — 통로가 하나뿐이라는 전제였고, 코드와 작업 항목이 다른 곳에
|
|
361
|
+
* 있는 프로젝트에서 그 전제가 깨졌다. 옛 기록(문자열 하나)도 그대로 읽는다: 형식이
|
|
362
|
+
* 바뀌었다는 이유로 이전 설치의 shadow 기록이 사라진 것처럼 보이면 안 된다.
|
|
303
363
|
*/
|
|
364
|
+
async function activeMonitorSources(store) {
|
|
365
|
+
const raw = await store.scope('monitor').get(ACTIVE_SOURCE_KEY);
|
|
366
|
+
if (!raw)
|
|
367
|
+
return ['github-poll'];
|
|
368
|
+
try {
|
|
369
|
+
const parsed = JSON.parse(raw);
|
|
370
|
+
if (Array.isArray(parsed) && parsed.every((id) => typeof id === 'string')) {
|
|
371
|
+
return parsed.length > 0 ? parsed : ['github-poll'];
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
// 옛 형식이다 — 문자열 하나
|
|
376
|
+
}
|
|
377
|
+
return [raw];
|
|
378
|
+
}
|
|
379
|
+
/** 통로별 scope. 기록이 하나뿐이던 시절의 호출부는 첫 통로를 본다. */
|
|
380
|
+
async function activeMonitorScopes(store) {
|
|
381
|
+
return (await activeMonitorSources(store)).map(monitorScope);
|
|
382
|
+
}
|
|
304
383
|
async function activeMonitorScope(store) {
|
|
305
|
-
|
|
306
|
-
return monitorScope(sourceId ?? 'github-poll');
|
|
384
|
+
return (await activeMonitorScopes(store))[0];
|
|
307
385
|
}
|
|
386
|
+
/**
|
|
387
|
+
* JAM 을 부르는 방법. **한 곳에서만 정한다** — 감시와 작업 조사가 서로 다른 방법으로
|
|
388
|
+
* JAM 을 부르면 한쪽만 되는 상태가 생긴다(실제로 그랬다: 감시 경로는 이 값을 아예 넘기지
|
|
389
|
+
* 않아 선언된 JAM binding 이 통로를 만들지 못했다).
|
|
390
|
+
*/
|
|
391
|
+
const jamLauncher = () => process.env.ASC_JAM_PATH
|
|
392
|
+
? { command: process.env.ASC_JAM_PATH, args: [] }
|
|
393
|
+
: { command: 'npx', args: ['--yes', '@jam-mcp/launcher'] };
|
|
394
|
+
/** 조립 입력에 실을 JAM 갈래. */
|
|
395
|
+
const jamComposition = (projectRoot) => {
|
|
396
|
+
const launcher = jamLauncher();
|
|
397
|
+
return { jam: { command: launcher.command, args: [...launcher.args, 'serve'], cwd: projectRoot } };
|
|
398
|
+
};
|
|
399
|
+
/** 이 빌드가 아는 adapter 들. 감시와 작업 조사가 같은 목록을 본다. */
|
|
400
|
+
const monitorAdapters = () => {
|
|
401
|
+
const launcher = jamLauncher();
|
|
402
|
+
return [
|
|
403
|
+
new GitHubAdapter(),
|
|
404
|
+
new GitLabAdapter(),
|
|
405
|
+
new JamAdapter(process.env.ASC_JAM_PATH ? {} : { command: launcher.command, args: launcher.args }),
|
|
406
|
+
];
|
|
407
|
+
};
|
|
308
408
|
/** user-owned ASC home. */
|
|
309
409
|
const ascHome = () => process.env.ASC_HOME ?? join(homedir(), '.asc');
|
|
310
410
|
const DECISION_ERROR = {
|
|
@@ -406,10 +506,43 @@ function reportNodeRuntime(check, asJson) {
|
|
|
406
506
|
console.error(` ${action.node.version} at ${action.node.path}\n ${action.display}`);
|
|
407
507
|
}
|
|
408
508
|
export async function runAscCommand(argv, entry = 'runtime') {
|
|
409
|
-
|
|
509
|
+
let parsed;
|
|
510
|
+
try {
|
|
511
|
+
parsed = parseArgsOrThrow(argv);
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
// 모르는 옵션은 사용자 입력 오류다 — Node 스택을 던지면 그때부터 도구를 의심하게 된다.
|
|
515
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
516
|
+
console.error(`Could not read the arguments: ${message.split('\n')[0]}`);
|
|
517
|
+
console.error('Run `asc --help` for the commands and flags this build understands.');
|
|
518
|
+
return 2;
|
|
519
|
+
}
|
|
520
|
+
const { values, positionals } = parsed;
|
|
521
|
+
// 실행 중인 버전을 묻는 유일한 공식 통로. 설치 안내가 버전을 핀으로 고정하는데
|
|
522
|
+
// 정작 지금 도는 것이 무엇인지 물을 방법이 없었다 (Windows 실전 실측 ASC-1).
|
|
523
|
+
if (values.version || positionals[0] === 'version') {
|
|
524
|
+
console.log(RELEASE_VERSION);
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
return await runParsedCommand(values, positionals, entry, argv);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
// **어느 명령이든** 자기가 띄운 도구 자식을 닫는다 (JAM MCP 서버 등).
|
|
532
|
+
//
|
|
533
|
+
// 예전에는 `proceed` 한 곳에서만 닫았다. 감시 경로가 JAM 통로를 열게 되자
|
|
534
|
+
// `monitor scan` 과 `runtime tick` 이 할 일을 다 하고도 종료하지 못했고, 등록된
|
|
535
|
+
// 서비스가 회차마다 그 프로세스를 하나씩 남겼다 — 실측에서 10분 넘게 살아 있었다.
|
|
536
|
+
// 닫는 자리를 명령마다 두면 언젠가 또 빠진다. 나가는 문은 하나다.
|
|
537
|
+
await closeToolClients();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function parseArgsOrThrow(argv) {
|
|
541
|
+
return parseArgs({
|
|
410
542
|
args: argv,
|
|
411
543
|
allowPositionals: true,
|
|
412
544
|
options: {
|
|
545
|
+
version: { type: 'boolean', default: false },
|
|
413
546
|
root: { type: 'string' },
|
|
414
547
|
parent: { type: 'string' },
|
|
415
548
|
'issued-by': { type: 'string' },
|
|
@@ -435,6 +568,7 @@ export async function runAscCommand(argv, entry = 'runtime') {
|
|
|
435
568
|
intent: { type: 'string' },
|
|
436
569
|
grant: { type: 'string' },
|
|
437
570
|
'interval-min': { type: 'string' },
|
|
571
|
+
detach: { type: 'boolean' },
|
|
438
572
|
'delta-min': { type: 'string' },
|
|
439
573
|
'reconcile-min': { type: 'string' },
|
|
440
574
|
'census-min': { type: 'string' },
|
|
@@ -509,6 +643,8 @@ export async function runAscCommand(argv, entry = 'runtime') {
|
|
|
509
643
|
help: { type: 'boolean', short: 'h', default: false },
|
|
510
644
|
},
|
|
511
645
|
});
|
|
646
|
+
}
|
|
647
|
+
async function runParsedCommand(values, positionals, entry, argv) {
|
|
512
648
|
const [group, command, target, extra] = positionals;
|
|
513
649
|
if (values.help || group === undefined) {
|
|
514
650
|
console.log(USAGE);
|
|
@@ -539,7 +675,15 @@ export async function runAscCommand(argv, entry = 'runtime') {
|
|
|
539
675
|
// 선택된 build로 넘길 것이 있으면 여기서 넘긴다. **선택 자체를 다루는 명령은 넘기지
|
|
540
676
|
// 않는다** — 잘못 가리키는 선택을 고치거나 들여다보는 명령이 그 선택 때문에 못 돌면
|
|
541
677
|
// 사람이 갇힌다.
|
|
542
|
-
|
|
678
|
+
// 기계 수준 명령은 **프로젝트와 무관하다.** 붙지 않은 자리에서도 답해야 하고,
|
|
679
|
+
// 등록된 서비스는 어느 프로젝트 안에서 도는 것이 아니다 (설계 §4.1).
|
|
680
|
+
const machineLevelRuntime = group === 'runtime' &&
|
|
681
|
+
(command === 'use' ||
|
|
682
|
+
command === 'status' ||
|
|
683
|
+
command === 'list' ||
|
|
684
|
+
command === 'service' ||
|
|
685
|
+
(command === 'tick' && Boolean(values.all)));
|
|
686
|
+
if (!machineLevelRuntime) {
|
|
543
687
|
const redispatched = await redispatchIfNeeded(argv);
|
|
544
688
|
if (redispatched !== null)
|
|
545
689
|
return redispatched;
|
|
@@ -552,12 +696,24 @@ export async function runAscCommand(argv, entry = 'runtime') {
|
|
|
552
696
|
if (group === 'runtime' && (command === 'use' || command === 'status')) {
|
|
553
697
|
return runRuntimeSelect(command, positionals[2], positionals[3], values);
|
|
554
698
|
}
|
|
699
|
+
if (group === 'runtime' && command === 'list')
|
|
700
|
+
return runRuntimeList(values);
|
|
701
|
+
if (group === 'runtime' && command === 'service')
|
|
702
|
+
return runRuntimeService(positionals[2], values);
|
|
703
|
+
// 기계 전체 회차 — 등록된 서비스가 부르는 갈래다.
|
|
704
|
+
if (group === 'runtime' && command === 'tick' && values.all)
|
|
705
|
+
return runRuntimeTickAll(values);
|
|
555
706
|
if (group === 'host')
|
|
556
707
|
return runHost(command, positionals[2], positionals[3], values);
|
|
557
708
|
// setup은 **붙기 전에도** 답을 줘야 한다. 아래 discoverRoot 실패는 exit 2로 끊는데,
|
|
558
709
|
// 그러면 "아직 안 붙었다"를 확인하려고 부른 명령이 안 붙었다는 이유로 죽는다.
|
|
559
710
|
if (group === 'setup')
|
|
560
711
|
return runSetup(command, values, entry);
|
|
712
|
+
// `front open` 도 같은 이유로 여기서 답한다. Host lifecycle이 부르는 갈래라
|
|
713
|
+
// **붙지 않은 자리에서 실패하면 안 된다** — 그 자리는 대부분 ASC와 무관한 프로젝트이고,
|
|
714
|
+
// 거기서 exit 2를 내는 것은 남의 세션에 오류를 얹는 일이다 (C-11 불변식 ⑪).
|
|
715
|
+
if (group === 'front' && command === 'open')
|
|
716
|
+
return runFrontOpen(values);
|
|
561
717
|
// adopt는 **붙기 전의 명령이다.** 붙을 Profile을 만드는 것이 일이므로 attach를 요구하면
|
|
562
718
|
// 순서가 뒤집힌다. 나머지 profile 명령은 아래 attach 경로에 그대로 남는다.
|
|
563
719
|
if (group === 'profile' && command === 'adopt')
|
|
@@ -612,7 +768,7 @@ export async function runAscCommand(argv, entry = 'runtime') {
|
|
|
612
768
|
return runFreeze(group, command, values, store);
|
|
613
769
|
switch (command) {
|
|
614
770
|
case 'list': {
|
|
615
|
-
const items = await operator.list({ all: values.all, ...(priority ? { priority } : {}) });
|
|
771
|
+
const items = await operator.list({ all: Boolean(values.all), ...(priority ? { priority } : {}) });
|
|
616
772
|
console.log(values.json ? JSON.stringify(items, null, 2) : renderer.renderList(items).text);
|
|
617
773
|
return 0;
|
|
618
774
|
}
|
|
@@ -923,7 +1079,7 @@ async function runInit(values) {
|
|
|
923
1079
|
installRoot: installRoot(),
|
|
924
1080
|
externalProfileRoot: externalProfileRoot(),
|
|
925
1081
|
...(attachedRoot ? { ascRoot: attachedRoot } : {}),
|
|
926
|
-
hosts: [{ id: 'claude', installed: await verifyInstalled(
|
|
1082
|
+
hosts: [{ id: 'claude', installed: await verifyInstalled(hostPaths()) }],
|
|
927
1083
|
bindings,
|
|
928
1084
|
declaredPolicies: declaredPolicies(attachedRoot ? await attachedRuntime(attachedRoot) : undefined),
|
|
929
1085
|
});
|
|
@@ -1033,11 +1189,18 @@ async function runRuntimeSelect(command, mode, source, values) {
|
|
|
1033
1189
|
if (command === 'status') {
|
|
1034
1190
|
const selection = await readRuntimeSelection(home);
|
|
1035
1191
|
const target = await resolveRuntimeTarget(selection);
|
|
1192
|
+
// 어느 build를 쓰는지(C-14)와 지금 관측하고 있는지(C-12)는 다른 사실이다. 한 화면에
|
|
1193
|
+
// 같이 두되 섞지 않는다 — 붙지 않은 곳에서는 아래 절이 통째로 없다.
|
|
1194
|
+
const background = await backgroundHere(values);
|
|
1195
|
+
// 서비스 등록은 workspace 와 무관한 기계의 사실이다. 같은 화면에 두되 섞지 않는다.
|
|
1196
|
+
const service = await serviceHealth(values);
|
|
1036
1197
|
if (values.json) {
|
|
1037
1198
|
console.log(JSON.stringify({
|
|
1038
1199
|
selection: selection ?? null,
|
|
1039
1200
|
target,
|
|
1040
1201
|
file: selectionPath(home),
|
|
1202
|
+
...(service ? { service } : {}),
|
|
1203
|
+
...(background ? { background } : {}),
|
|
1041
1204
|
// 해법은 데이터로 준다 — agent가 산문에서 경로를 추론하지 않는다
|
|
1042
1205
|
...('code' in target ? { action: remediationAction(target) } : {}),
|
|
1043
1206
|
}, null, 2));
|
|
@@ -1049,6 +1212,11 @@ async function runRuntimeSelect(command, mode, source, values) {
|
|
|
1049
1212
|
return 1;
|
|
1050
1213
|
}
|
|
1051
1214
|
console.log(runtimeSelectionLine(target));
|
|
1215
|
+
if (service)
|
|
1216
|
+
console.log(service.line);
|
|
1217
|
+
if (background)
|
|
1218
|
+
for (const line of renderBackground(background))
|
|
1219
|
+
console.log(line);
|
|
1052
1220
|
return 0;
|
|
1053
1221
|
}
|
|
1054
1222
|
if (mode !== 'package' && mode !== 'development') {
|
|
@@ -1150,22 +1318,144 @@ async function detectSetupState(values, entry) {
|
|
|
1150
1318
|
const { root: projectRoot, git } = await discoverProjectRoot(process.cwd());
|
|
1151
1319
|
const resolution = await resolveRoot(process.cwd(), values.root);
|
|
1152
1320
|
const ascRoot = resolution.kind === 'UNRESOLVED' ? undefined : resolution.root;
|
|
1321
|
+
// 디렉터리가 있다고 붙은 것이 아니다 — profile.lock까지 서야 붙은 것이다. 빈 skeleton을
|
|
1322
|
+
// "붙어 있음"으로 넘기면 plan이 applied를 답하며 실패할 proceed를 준다 (실측 ASC-2).
|
|
1323
|
+
const attachmentBroken = ascRoot ? (await inspectSetup(ascRoot)).attachment === 'BROKEN' : false;
|
|
1153
1324
|
const scope = values.scope === 'project' ? 'project' : 'local';
|
|
1154
|
-
const hostReport = await verifyInstall(
|
|
1325
|
+
const hostReport = await verifyInstall(hostPaths());
|
|
1155
1326
|
return {
|
|
1156
1327
|
entry,
|
|
1157
1328
|
projectRoot,
|
|
1158
1329
|
git,
|
|
1159
1330
|
...(ascRoot ? { ascRoot } : {}),
|
|
1331
|
+
...(attachmentBroken ? { attachmentBroken } : {}),
|
|
1160
1332
|
...(values.profile ? { requestedProfile: values.profile } : {}),
|
|
1161
1333
|
profileCandidates: await availableProfiles(installRoot(), externalProfileRoot()),
|
|
1162
1334
|
scope,
|
|
1163
1335
|
host: [{ id: 'claude', status: hostReport.status }],
|
|
1336
|
+
// Profile 이 작업 도구를 선언했으면 그 준비 상태까지 본다 (설계 §9.3).
|
|
1337
|
+
...(await workBindingState(ascRoot, projectRoot)),
|
|
1338
|
+
// 이 기계의 지속 등록. **별도 onboarding 을 만들지 않는다** — 같은 계획에 함께 든다.
|
|
1339
|
+
...(await persistentRuntimeState(values)),
|
|
1164
1340
|
// **bootstrap으로 들어왔을 때만 본다.** 설치된 runtime이 자기를 다시 설치할 이유가
|
|
1165
1341
|
// 없고, 그 축을 안 그리면 plan은 설치된 `asc` 를 전제하지도 않는다 (C-14 §3.4).
|
|
1166
1342
|
...(entry === 'bootstrap' ? { stableRuntime: await detectStableInstall(nodeProcessRunner) } : {}),
|
|
1167
1343
|
};
|
|
1168
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* 지금 도는 것이 **설치된 패키지인가.**
|
|
1347
|
+
*
|
|
1348
|
+
* 등록물은 지금 도는 진입점의 절대 경로를 박는다. checkout 이나 일회용 prefix 에서
|
|
1349
|
+
* 등록하면 사라질 경로를 기계에 남기는 일이 된다 — 등록은 이 기계에 오래 남을 설치본이
|
|
1350
|
+
* 스스로 할 때만 의미가 있다.
|
|
1351
|
+
*/
|
|
1352
|
+
function runningFromInstalledPackage() {
|
|
1353
|
+
return fileURLToPath(import.meta.url).replace(/\\/g, '/').includes('/node_modules/@asc-agent/runtime/');
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* 지금 이 실행에서 기계 등록을 다뤄도 되는가.
|
|
1357
|
+
*
|
|
1358
|
+
* **기계 서비스는 HOME 으로 격리되지 않는다.** ASC_HOME 을 옮겨도 launchd·Task
|
|
1359
|
+
* Scheduler·systemd 는 같은 기계 하나를 본다 — 그래서 격리된 환경에서 setup 을 통째로
|
|
1360
|
+
* 돌려 보는 검증에는 이 축을 끄는 길이 필요하다. 그 길이 없으면 검증이 실행한 기계에
|
|
1361
|
+
* 진짜 서비스를 남긴다.
|
|
1362
|
+
*/
|
|
1363
|
+
function serviceRegistrationAllowed() {
|
|
1364
|
+
if (process.env.ASC_SERVICE === 'off')
|
|
1365
|
+
return false;
|
|
1366
|
+
return runningFromInstalledPackage();
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* 이 기계에 ASC runtime 이 등록돼 있는가 (설계 §6, Gate 7).
|
|
1370
|
+
*
|
|
1371
|
+
* 물어볼 수 없으면 이 축을 그리지 않는다 — 모르는 것을 "해야 한다"로 적으면 설치되지
|
|
1372
|
+
* 않은 기계에서 setup 이 영영 끝나지 않는다.
|
|
1373
|
+
*/
|
|
1374
|
+
async function persistentRuntimeState(values) {
|
|
1375
|
+
if (!serviceRegistrationAllowed())
|
|
1376
|
+
return {};
|
|
1377
|
+
const adapter = serviceAdapter();
|
|
1378
|
+
if (!adapter)
|
|
1379
|
+
return {};
|
|
1380
|
+
const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
|
|
1381
|
+
if (!plan)
|
|
1382
|
+
return {};
|
|
1383
|
+
return {
|
|
1384
|
+
persistentRuntime: {
|
|
1385
|
+
action: plan.action,
|
|
1386
|
+
adapter: adapter.id,
|
|
1387
|
+
...(plan.state.kind !== 'CURRENT' && 'detail' in plan.state && plan.state.detail
|
|
1388
|
+
? { detail: plan.state.detail }
|
|
1389
|
+
: {}),
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Profile 이 선언한 작업 도구가 지금 쓸 수 있는가 (설계 §9.3).
|
|
1395
|
+
*
|
|
1396
|
+
* **선언이 없으면 아무것도 보지 않는다** — 선언되지 않은 도구를 준비시키는 것은
|
|
1397
|
+
* 사람이 하지 않은 결정을 대신 내리는 일이다.
|
|
1398
|
+
*
|
|
1399
|
+
* 판정은 그 도구가 한다. 여기서 하는 것은 그 판정을 setup 이 읽을 수 있는 모양으로
|
|
1400
|
+
* 옮기는 것뿐이고, 도구를 실행하지 못하면 이 축을 그리지 않는다(모르는 것을
|
|
1401
|
+
* "준비 안 됨"으로 적지 않는다).
|
|
1402
|
+
*/
|
|
1403
|
+
async function workBindingState(ascRoot, projectRoot) {
|
|
1404
|
+
if (!ascRoot)
|
|
1405
|
+
return {};
|
|
1406
|
+
const runtime = await attachedRuntime(ascRoot);
|
|
1407
|
+
const declared = (runtime?.layers.profile.bindings ?? []).find((binding) => binding.role === 'work');
|
|
1408
|
+
// 지금 준비 상태를 물어볼 수 있는 도구는 JAM 뿐이다. 다른 adapter 가 생기면 여기가 는다.
|
|
1409
|
+
if (!declared || declared.adapter !== 'jam')
|
|
1410
|
+
return {};
|
|
1411
|
+
const launcher = jamLauncher();
|
|
1412
|
+
const adapter = new JamAdapter(process.env.ASC_JAM_PATH ? {} : { command: launcher.command, args: launcher.args });
|
|
1413
|
+
const status = await adapter
|
|
1414
|
+
.runtime({ projectRoot, env: process.env })
|
|
1415
|
+
.catch(() => ({ state: 'UNAVAILABLE', detail: 'could not ask' }));
|
|
1416
|
+
if (status.state === 'AVAILABLE') {
|
|
1417
|
+
return { workBinding: { adapter: 'jam', resource: declared.resource, ready: true } };
|
|
1418
|
+
}
|
|
1419
|
+
const remedy = adapter.lastRemedy();
|
|
1420
|
+
// 도구를 아예 못 불렀으면 이 축을 그리지 않는다 — 그 상태에서 "고쳐야 한다"고 적으면
|
|
1421
|
+
// 설치되지 않은 기계에서 setup 이 영영 끝나지 않는다.
|
|
1422
|
+
if (!remedy)
|
|
1423
|
+
return {};
|
|
1424
|
+
const version = await jamVersion(projectRoot);
|
|
1425
|
+
return {
|
|
1426
|
+
workBinding: {
|
|
1427
|
+
adapter: 'jam',
|
|
1428
|
+
resource: declared.resource,
|
|
1429
|
+
ready: false,
|
|
1430
|
+
remedy: remedy.kind,
|
|
1431
|
+
detail: remedy.detail,
|
|
1432
|
+
...(version ? { version } : {}),
|
|
1433
|
+
},
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* JAM 이 말한 자기 버전. **여기서 정하지 않는다** — 두 제품의 릴리스를 묶지 않기 위해서다.
|
|
1438
|
+
* 못 읽으면 undefined 이고, 그러면 계획이 그 도구를 부르지 않는다.
|
|
1439
|
+
*/
|
|
1440
|
+
async function jamVersion(projectRoot) {
|
|
1441
|
+
const launcher = jamLauncher();
|
|
1442
|
+
const { command, args } = resolveJamCommand(process.env, launcher.command, launcher.args);
|
|
1443
|
+
try {
|
|
1444
|
+
const { stdout } = await execFileAsync(command, [...args, 'doctor', '--json'], { cwd: projectRoot });
|
|
1445
|
+
return JSON.parse(stdout).axes?.packageVersion;
|
|
1446
|
+
}
|
|
1447
|
+
catch (error) {
|
|
1448
|
+
const stdout = error.stdout;
|
|
1449
|
+
if (!stdout)
|
|
1450
|
+
return undefined;
|
|
1451
|
+
try {
|
|
1452
|
+
return JSON.parse(stdout).axes?.packageVersion;
|
|
1453
|
+
}
|
|
1454
|
+
catch {
|
|
1455
|
+
return undefined;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1169
1459
|
/**
|
|
1170
1460
|
* `npm` 을 shim으로 부르지 않는다.
|
|
1171
1461
|
*
|
|
@@ -1346,6 +1636,28 @@ async function runSetupLifecycle(command, values, entry) {
|
|
|
1346
1636
|
if (code !== 0)
|
|
1347
1637
|
throw new Error(`attach 실패 (exit ${code})`);
|
|
1348
1638
|
},
|
|
1639
|
+
registerPersistentRuntime: async () => {
|
|
1640
|
+
const adapter = serviceAdapter();
|
|
1641
|
+
if (!adapter)
|
|
1642
|
+
return;
|
|
1643
|
+
try {
|
|
1644
|
+
await adapter.install(serviceCommand(serviceInterval(values)));
|
|
1645
|
+
console.log(`Persistent runtime registered with ${adapter.id}.`);
|
|
1646
|
+
}
|
|
1647
|
+
catch (error) {
|
|
1648
|
+
// **등록 실패는 attach 실패가 아니다.** 기계 등록은 이 프로젝트가 붙는 것과
|
|
1649
|
+
// 다른 축이고, 서비스 관리자가 없거나 거절하는 환경은 흔하다. 조용히 넘기지도
|
|
1650
|
+
// 않는다 — 무엇이 안 됐는지 말하고, 상태는 `runtime status` 가 계속 답한다.
|
|
1651
|
+
console.error(`Persistent runtime could not be registered with ${adapter.id}: ${String(error)}`);
|
|
1652
|
+
}
|
|
1653
|
+
},
|
|
1654
|
+
// 그 도구의 공식 setup 을 부른다. ASC 가 그 설정을 손으로 조립하지 않는다.
|
|
1655
|
+
setupWorkBinding: async (change) => {
|
|
1656
|
+
const outcome = await healJam({ cwd: process.cwd(), version: change.version });
|
|
1657
|
+
console.log(healLine(outcome));
|
|
1658
|
+
if (outcome.kind === 'FAILED')
|
|
1659
|
+
throw new Error(healLine(outcome));
|
|
1660
|
+
},
|
|
1349
1661
|
installHost: async (change) => {
|
|
1350
1662
|
const code = await runHost('claude', 'install', undefined, { ...values, json: false });
|
|
1351
1663
|
if (code !== 0)
|
|
@@ -1640,7 +1952,7 @@ async function runHost(provider, command, target, values) {
|
|
|
1640
1952
|
console.error(`Unknown host: ${provider ?? '(none)'} — only claude is supported today`);
|
|
1641
1953
|
return 2;
|
|
1642
1954
|
}
|
|
1643
|
-
const paths =
|
|
1955
|
+
const paths = hostPaths();
|
|
1644
1956
|
switch (command) {
|
|
1645
1957
|
case 'install': {
|
|
1646
1958
|
const before = await verifyInstall(paths);
|
|
@@ -2056,13 +2368,7 @@ function dependencyCandidates(workItem) {
|
|
|
2056
2368
|
*/
|
|
2057
2369
|
async function buildWorkIngress(store, root, runtime, resolved) {
|
|
2058
2370
|
const { root: projectRoot } = await discoverProjectRoot(process.cwd());
|
|
2059
|
-
|
|
2060
|
-
const jamCommand = process.env.ASC_JAM_PATH ? undefined : { command: 'npx', args: ['--yes', '@jam-mcp/launcher'] };
|
|
2061
|
-
const adapters = [
|
|
2062
|
-
new GitHubAdapter(),
|
|
2063
|
-
new GitLabAdapter(),
|
|
2064
|
-
new JamAdapter(jamCommand ?? {}),
|
|
2065
|
-
];
|
|
2371
|
+
const adapters = monitorAdapters();
|
|
2066
2372
|
const declared = resolved?.layers.profile.bindings ?? [];
|
|
2067
2373
|
const plan = await composeBindings({
|
|
2068
2374
|
context: { projectRoot, env: process.env },
|
|
@@ -2083,7 +2389,7 @@ async function buildWorkIngress(store, root, runtime, resolved) {
|
|
|
2083
2389
|
// capability 해석은 후보가 유일할 때 이미 스스로 풀린다.
|
|
2084
2390
|
roles: rolesFor(plan, declared),
|
|
2085
2391
|
perPage: 30,
|
|
2086
|
-
|
|
2392
|
+
...jamComposition(projectRoot),
|
|
2087
2393
|
endpointFor: (binding) => endpointOf(adapters, binding),
|
|
2088
2394
|
});
|
|
2089
2395
|
const work = ports.resourceContext;
|
|
@@ -2345,9 +2651,16 @@ async function runSession(command, target, values, store, resolved) {
|
|
|
2345
2651
|
const at = new Date().toISOString();
|
|
2346
2652
|
switch (command) {
|
|
2347
2653
|
case 'issue': {
|
|
2654
|
+
// 누락과 오값은 다른 실수다 — "필수"라고 답하면 이미 준 사람은 자기가 무엇을
|
|
2655
|
+
// 틀렸는지 모른다 (Windows 실전 실측 ASC-3).
|
|
2656
|
+
const roleChoices = SessionRole.options.join('|');
|
|
2657
|
+
if (values.role === undefined || !values.goal) {
|
|
2658
|
+
console.error(`--role and --goal are required (role: ${roleChoices})`);
|
|
2659
|
+
return 2;
|
|
2660
|
+
}
|
|
2348
2661
|
const role = SessionRole.safeParse(values.role);
|
|
2349
|
-
if (!role.success
|
|
2350
|
-
console.error('
|
|
2662
|
+
if (!role.success) {
|
|
2663
|
+
console.error(`'${String(values.role)}' is not a role this build knows — choose one of: ${roleChoices}`);
|
|
2351
2664
|
return 2;
|
|
2352
2665
|
}
|
|
2353
2666
|
const authority = parseAuthority(values.authority);
|
|
@@ -2407,12 +2720,19 @@ async function runSession(command, target, values, store, resolved) {
|
|
|
2407
2720
|
console.error('At least one --evidence and one --why are required (C-13 §4).');
|
|
2408
2721
|
return 2;
|
|
2409
2722
|
}
|
|
2723
|
+
// 오값을 Core까지 흘리면 ZodError 원문이 사람에게 떨어진다 (실측 ASC-5).
|
|
2724
|
+
// 사용자 입력 검증은 Surface의 몫이다 — 여기서 고를 수 있는 값을 그대로 준다.
|
|
2725
|
+
const parsedClass = DecisionClass.safeParse(decisionClass);
|
|
2726
|
+
if (!parsedClass.success) {
|
|
2727
|
+
console.error(`'${decisionClass}' is not a decision class — choose one of: ${DecisionClass.options.join(', ')}`);
|
|
2728
|
+
return 2;
|
|
2729
|
+
}
|
|
2410
2730
|
const audit = auditLedger(store);
|
|
2411
2731
|
const recorded = await audit.decide({
|
|
2412
2732
|
sessionId: target,
|
|
2413
2733
|
actor: values.as ?? values.principal ?? '(미상)',
|
|
2414
2734
|
ownership: values.ownership ?? [],
|
|
2415
|
-
class:
|
|
2735
|
+
class: parsedClass.data,
|
|
2416
2736
|
evidenceRefs: [evidence[0], ...evidence.slice(1)],
|
|
2417
2737
|
selectedOption: selected,
|
|
2418
2738
|
alternatives: values.alternative ?? [],
|
|
@@ -3004,17 +3324,7 @@ function parseEnumArg(raw, allowed, flag) {
|
|
|
3004
3324
|
console.error(`Invalid value for --${flag}: ${String(raw)} (allowed: ${allowed.join('|')})`);
|
|
3005
3325
|
return null;
|
|
3006
3326
|
}
|
|
3007
|
-
|
|
3008
|
-
/**
|
|
3009
|
-
* 감시 엔진을 세운다. **조립과 출력을 나눈다** — 상시 Runtime(C-12)이 같은 엔진을
|
|
3010
|
-
* 주기적으로 부르려면 조립이 명령 출력 안에 갇혀 있으면 안 된다.
|
|
3011
|
-
*/
|
|
3012
|
-
async function buildMonitorEngine(values, store, resolved) {
|
|
3013
|
-
const token = await discoverToken();
|
|
3014
|
-
if (!token) {
|
|
3015
|
-
console.error('No GitHub token found. Set ASC_GITHUB_TOKEN, or run `gh auth login`.');
|
|
3016
|
-
return { ok: false, code: 2 };
|
|
3017
|
-
}
|
|
3327
|
+
async function buildMonitorEngines(values, store, resolved) {
|
|
3018
3328
|
// 감시 대상도, 누가 나인지도, 누가 승인자인지도 전부 설정에서 온다.
|
|
3019
3329
|
// 명령줄로 받던 값들은 각자 제자리(Profile·Override)를 찾았다.
|
|
3020
3330
|
const repo = resolved.layers.profile.project.repository;
|
|
@@ -3030,79 +3340,307 @@ async function buildMonitorEngine(values, store, resolved) {
|
|
|
3030
3340
|
// provider를 CLI가 고르지 않는다. 선언된 요구와 실제 remote에서 Binding이 풀리고,
|
|
3031
3341
|
// 갈리면 고르지 않고 말한다 — silent substitution 0 (C-11 §7).
|
|
3032
3342
|
const { root: projectRoot } = await discoverProjectRoot(process.cwd());
|
|
3033
|
-
const adapters =
|
|
3343
|
+
const adapters = monitorAdapters();
|
|
3034
3344
|
const declared = resolved.layers.profile.bindings ?? [];
|
|
3035
3345
|
const plan = await composeBindings({
|
|
3036
3346
|
context: { projectRoot, env: process.env },
|
|
3037
3347
|
adapters,
|
|
3038
3348
|
roles: declared.map((b) => ({ adapterId: b.adapter, resource: b.resource, role: b.role })),
|
|
3039
3349
|
});
|
|
3040
|
-
const
|
|
3350
|
+
const built = await buildObservationChannels({
|
|
3351
|
+
plan,
|
|
3352
|
+
perPage: 30,
|
|
3353
|
+
...jamComposition(projectRoot),
|
|
3354
|
+
endpointFor: (binding) => endpointOf(adapters, binding),
|
|
3355
|
+
});
|
|
3356
|
+
// canonical.read 처럼 한 곳이어야 의미가 서는 것은 예전 경로 그대로 역할로 고른다.
|
|
3357
|
+
const singular = await buildRuntimePorts({
|
|
3041
3358
|
plan,
|
|
3042
3359
|
roles: rolesFor(plan, declared),
|
|
3043
3360
|
perPage: 30,
|
|
3361
|
+
...jamComposition(projectRoot),
|
|
3044
3362
|
endpointFor: (binding) => endpointOf(adapters, binding),
|
|
3045
3363
|
});
|
|
3046
|
-
if (
|
|
3364
|
+
if (built.channels.length === 0) {
|
|
3047
3365
|
console.error('No monitoring channel could be built:');
|
|
3048
|
-
for (const reason of
|
|
3366
|
+
for (const reason of built.unavailable)
|
|
3049
3367
|
console.error(` - ${reason}`);
|
|
3050
3368
|
console.error('\nDeclare which provider takes which role in the Profile bindings.');
|
|
3051
3369
|
return { ok: false, code: 2 };
|
|
3052
3370
|
}
|
|
3053
3371
|
const canonicalPaths = resolved.layers.profile.canonical.sources.flatMap((source) => source.paths);
|
|
3054
3372
|
const myRoles = resolved.layers.override?.monitor.roles ?? [];
|
|
3055
|
-
const
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3373
|
+
const channels = built.channels.map((channel) => {
|
|
3374
|
+
const changeContext = channel.changeContext;
|
|
3375
|
+
const engine = new MonitorEngine({
|
|
3376
|
+
store,
|
|
3377
|
+
source: channel.eventSource,
|
|
3378
|
+
// 정본 대조는 채널이 아니라 프로젝트의 사실이다 — 역할로 고른 하나를 함께 쓴다.
|
|
3379
|
+
...(singular.scm ? { scm: singular.scm } : {}),
|
|
3380
|
+
...(channel.inventory ? { inventory: channel.inventory } : {}),
|
|
3381
|
+
// 밖에서 알아 온 사실을 실제로 공급한다 (C-07 §2~§4). 이것이 없으면 Relevance·
|
|
3382
|
+
// Shadow·Material Change가 코드에만 있고 실행 경로에는 없다.
|
|
3383
|
+
...(changeContext
|
|
3384
|
+
? {
|
|
3385
|
+
observe: buildEventObservation({
|
|
3386
|
+
change: changeContext,
|
|
3387
|
+
...(resolved.ownership ? { ownership: resolved.ownership } : {}),
|
|
3388
|
+
...(myRoles.length ? { myRoles } : {}),
|
|
3389
|
+
...(canonicalPaths.length ? { canonicalPaths } : {}),
|
|
3390
|
+
}),
|
|
3391
|
+
}
|
|
3392
|
+
: {}),
|
|
3393
|
+
// scope는 실제로 붙은 source가 정한다 — 채널이 둘이면 기록도 둘로 갈린다.
|
|
3394
|
+
observations: new ObservationLedger(store.scope(monitorScope(channel.eventSource.id))),
|
|
3395
|
+
// 조사 단계가 요청하는 통로들. 없는 것은 그 단계가 판정 불성립으로 남는다 (C-07 §6.2).
|
|
3396
|
+
// 없는 것을 있는 척하지 않는다.
|
|
3397
|
+
investigation: {
|
|
3398
|
+
...(channel.resourceContext ? { resource: channel.resourceContext } : {}),
|
|
3399
|
+
...(changeContext ? { change: changeContext } : {}),
|
|
3400
|
+
},
|
|
3401
|
+
// Core는 Profile을 모른다 — 조사에 필요한 프로젝트 사실을 Surface가 꺼내 준다.
|
|
3402
|
+
investigationContext: () => ({
|
|
3403
|
+
...(resolved.ownership ? { ownership: resolved.ownership } : {}),
|
|
3404
|
+
canonicalPaths,
|
|
3405
|
+
}),
|
|
3406
|
+
config: resolved.monitor,
|
|
3407
|
+
authorizedApprover: approver,
|
|
3408
|
+
canonicalSources: resolved.canonicalSources,
|
|
3409
|
+
// 처음 돌 때 과거를 통째로 긁으면 사람이 읽을 수 없다 (OM §18)
|
|
3410
|
+
...(values.backfill ? {} : { startFrom: new Date().toISOString() }),
|
|
3411
|
+
});
|
|
3412
|
+
return {
|
|
3413
|
+
engine,
|
|
3414
|
+
sourceId: channel.eventSource.id,
|
|
3415
|
+
label: `${channel.role ?? '(undeclared)'} · ${channel.adapterId}:${channel.resource}`,
|
|
3416
|
+
};
|
|
3091
3417
|
});
|
|
3092
|
-
await
|
|
3418
|
+
await rememberMonitorSources(store, channels.map((channel) => channel.sourceId));
|
|
3093
3419
|
// **밀려난 후보를 말한다.** 실 프로젝트 관측에서 나온 것이다: primary가 자격 없음으로
|
|
3094
3420
|
// 빠지고 mirror에 조용히 붙으면 "감시가 도는데 아무것도 안 잡히는" 상태가 된다.
|
|
3095
3421
|
// 고르는 것 자체는 막지 않되, 무엇이 밀렸는지 모르게 두지 않는다 (C-11 §7).
|
|
3096
3422
|
const skipped = plan.bindings.filter((b) => b.state !== 'AVAILABLE' && b.state !== 'DEGRADED');
|
|
3097
3423
|
if (skipped.length > 0) {
|
|
3098
|
-
console.error(
|
|
3424
|
+
console.error('Some candidates could not be used:');
|
|
3099
3425
|
for (const binding of skipped) {
|
|
3100
3426
|
console.error(` - ${binding.adapterId}:${binding.resource} — ${binding.state}${binding.detail ? ` (${binding.detail})` : ''}`);
|
|
3101
3427
|
}
|
|
3102
3428
|
}
|
|
3103
|
-
for (const reason of
|
|
3429
|
+
for (const reason of built.unavailable)
|
|
3104
3430
|
console.error(` - ${reason}`);
|
|
3105
|
-
return { ok: true,
|
|
3431
|
+
return { ok: true, channels, repo };
|
|
3432
|
+
}
|
|
3433
|
+
/**
|
|
3434
|
+
* 이 자리에서 background runtime이 돌고 있는가.
|
|
3435
|
+
*
|
|
3436
|
+
* **붙지 않았으면 없는 것으로 답한다** (`null`). `runtime status` 는 붙기 전에도 답해야
|
|
3437
|
+
* 하는 명령이라(C-14 §5) 여기서 attach를 요구하면 그 성질이 깨진다.
|
|
3438
|
+
*/
|
|
3439
|
+
async function backgroundHere(values) {
|
|
3440
|
+
const root = await discoverRoot(process.cwd(), values.root).catch(() => null);
|
|
3441
|
+
if (!root)
|
|
3442
|
+
return null;
|
|
3443
|
+
try {
|
|
3444
|
+
const scope = new MarkdownStateStore(root).scope('runtime');
|
|
3445
|
+
// 기본 주기 1분에서 나온 회수 기준. start가 다른 주기를 썼다면 그쪽이 더 길 뿐이고,
|
|
3446
|
+
// 살아 있는 lease를 죽은 것으로 읽지는 않는다 — heartbeat가 회차마다 갱신된다.
|
|
3447
|
+
return await readBackground(scope, staleAfter(60_000));
|
|
3448
|
+
}
|
|
3449
|
+
catch {
|
|
3450
|
+
// 읽지 못한 것을 "안 돌고 있다"로 적지 않는다 (C-12 불변식 ⑫).
|
|
3451
|
+
return null;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
/**
|
|
3455
|
+
* 이 기계의 지속 등록 상태 (설계 §13.1).
|
|
3456
|
+
*
|
|
3457
|
+
* PID 는 여기 없다 — 등록물이 정본이고 프로세스는 갈아 끼우는 실행 인스턴스다
|
|
3458
|
+
* (설계 §4.2). 물어볼 수 없으면 `null` 이고, 그때 화면에 그 줄이 없다.
|
|
3459
|
+
*/
|
|
3460
|
+
async function serviceHealth(values) {
|
|
3461
|
+
const adapter = serviceAdapter();
|
|
3462
|
+
if (!adapter)
|
|
3463
|
+
return null;
|
|
3464
|
+
const plan = await planPersistentRuntime(adapter, serviceCommand(serviceInterval(values))).catch(() => null);
|
|
3465
|
+
if (!plan)
|
|
3466
|
+
return null;
|
|
3467
|
+
return { adapter: adapter.id, action: plan.action, line: persistentRuntimeLine(adapter.id, plan) };
|
|
3468
|
+
}
|
|
3469
|
+
/**
|
|
3470
|
+
* 이 기계가 아는 workspace 들 (설계 §6).
|
|
3471
|
+
*
|
|
3472
|
+
* **두 번째 등록부를 읽지 않는다** — workspace index 하나에서 계산한다.
|
|
3473
|
+
*/
|
|
3474
|
+
async function machineWorkspaces() {
|
|
3475
|
+
const home = ascHome();
|
|
3476
|
+
const index = await readIndex(home);
|
|
3477
|
+
return viewWorkspaces(Object.values(index.workspaces).map((workspace) => ({
|
|
3478
|
+
workspaceId: workspace.workspaceId,
|
|
3479
|
+
root: join(home, 'workspaces', workspace.workspaceId),
|
|
3480
|
+
aliases: workspace.aliases,
|
|
3481
|
+
locators: locatorsOf(index, workspace.workspaceId).map((entry) => entry.locator),
|
|
3482
|
+
})), (path) => existsSync(path));
|
|
3483
|
+
}
|
|
3484
|
+
/**
|
|
3485
|
+
* `asc runtime list` — 기계 전체 화면 (설계 §13.2).
|
|
3486
|
+
*
|
|
3487
|
+
* workspace 마다 `cd` 해서 status 를 반복하게 만들지 않는다. 여기서 밖을 치지 않는다 —
|
|
3488
|
+
* 이것은 읽기이고, 관측은 회차가 한다.
|
|
3489
|
+
*/
|
|
3490
|
+
async function runRuntimeList(values) {
|
|
3491
|
+
const views = await machineWorkspaces();
|
|
3492
|
+
if (values.json) {
|
|
3493
|
+
console.log(JSON.stringify({ workspaces: views }, null, 2));
|
|
3494
|
+
return 0;
|
|
3495
|
+
}
|
|
3496
|
+
for (const line of renderWorkspaces(views))
|
|
3497
|
+
console.log(line);
|
|
3498
|
+
return 0;
|
|
3499
|
+
}
|
|
3500
|
+
/** 이 OS 의 등록 통로. 모르는 OS 면 `null` — 없는 것을 있는 척하지 않는다. */
|
|
3501
|
+
function serviceAdapter() {
|
|
3502
|
+
switch (serviceAdapterFor(process.platform)) {
|
|
3503
|
+
case 'launchd':
|
|
3504
|
+
return launchdAdapter();
|
|
3505
|
+
case 'schtasks':
|
|
3506
|
+
return schtasksAdapter();
|
|
3507
|
+
case 'systemd-user':
|
|
3508
|
+
return systemdUserAdapter();
|
|
3509
|
+
default:
|
|
3510
|
+
return null;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* 등록물이 실행할 명령. **한 회차만 돈다.**
|
|
3515
|
+
*
|
|
3516
|
+
* 지금 도는 실행 파일과 진입점을 그대로 쓴다 — 어느 build 를 쓸지는 그 진입점이 다시
|
|
3517
|
+
* 정하므로(C-14), 등록물이 build 를 고르는 일은 없다.
|
|
3518
|
+
*/
|
|
3519
|
+
function serviceCommand(intervalSeconds) {
|
|
3520
|
+
return {
|
|
3521
|
+
program: process.execPath,
|
|
3522
|
+
args: [fileURLToPath(import.meta.url), 'runtime', 'tick', '--all'],
|
|
3523
|
+
intervalSeconds,
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
const serviceInterval = (values) => values['interval-min'] === undefined ? 5 * 60 : Math.max(60, Number(values['interval-min']) * 60);
|
|
3527
|
+
/**
|
|
3528
|
+
* `asc runtime service` — 이 기계의 지속 등록 (설계 §4).
|
|
3529
|
+
*
|
|
3530
|
+
* **workspace 마다 하나가 아니다.** 사용자/기계당 하나이고, 그 하나가 아는 workspace
|
|
3531
|
+
* 전부를 돌본다. 등록물은 우리가 만든 것만 다룬다.
|
|
3532
|
+
*/
|
|
3533
|
+
async function runRuntimeService(command, values) {
|
|
3534
|
+
const adapter = serviceAdapter();
|
|
3535
|
+
if (!adapter) {
|
|
3536
|
+
const detail = `no user-scope service manager for ${process.platform}`;
|
|
3537
|
+
if (values.json)
|
|
3538
|
+
console.log(JSON.stringify({ supported: false, detail }, null, 2));
|
|
3539
|
+
else
|
|
3540
|
+
console.error(`Persistent runtime: ${detail}`);
|
|
3541
|
+
// 못 하는 것을 "했다"로 적지 않는다. 다만 이것이 오류는 아니다.
|
|
3542
|
+
return 0;
|
|
3543
|
+
}
|
|
3544
|
+
const wanted = serviceCommand(serviceInterval(values));
|
|
3545
|
+
if (command === undefined || command === 'status') {
|
|
3546
|
+
const plan = await planPersistentRuntime(adapter, wanted);
|
|
3547
|
+
if (values.json) {
|
|
3548
|
+
console.log(JSON.stringify({ adapter: adapter.id, ...plan }, null, 2));
|
|
3549
|
+
return 0;
|
|
3550
|
+
}
|
|
3551
|
+
console.log(persistentRuntimeLine(adapter.id, plan));
|
|
3552
|
+
return 0;
|
|
3553
|
+
}
|
|
3554
|
+
if (command === 'install') {
|
|
3555
|
+
const plan = await planPersistentRuntime(adapter, wanted);
|
|
3556
|
+
if (plan.action === 'unsupported') {
|
|
3557
|
+
console.error(persistentRuntimeLine(adapter.id, plan));
|
|
3558
|
+
return 1;
|
|
3559
|
+
}
|
|
3560
|
+
if (plan.action === 'none') {
|
|
3561
|
+
// 멱등이다 — 같은 것을 다시 등록하지 않는다
|
|
3562
|
+
console.log(persistentRuntimeLine(adapter.id, plan));
|
|
3563
|
+
return 0;
|
|
3564
|
+
}
|
|
3565
|
+
await adapter.install(wanted);
|
|
3566
|
+
console.log(`Persistent runtime registered with ${adapter.id} — one pass every ${wanted.intervalSeconds / 60} min.`);
|
|
3567
|
+
return 0;
|
|
3568
|
+
}
|
|
3569
|
+
if (command === 'uninstall') {
|
|
3570
|
+
await adapter.uninstall();
|
|
3571
|
+
console.log(`Persistent runtime registration removed from ${adapter.id}.`);
|
|
3572
|
+
return 0;
|
|
3573
|
+
}
|
|
3574
|
+
console.error(`Unknown runtime service command: ${command}\n\n${USAGE}`);
|
|
3575
|
+
return 2;
|
|
3576
|
+
}
|
|
3577
|
+
/**
|
|
3578
|
+
* 기계 전체 한 회차 (설계 §7).
|
|
3579
|
+
*
|
|
3580
|
+
* 싱글턴은 **판단하지 않는다** — 어느 workspace 가 지금 돌 수 있는지만 고르고, 회차 자체는
|
|
3581
|
+
* 기존 경로가 그대로 돈다. workspace 마다 별개의 짧은 프로세스로 도는 이유는 둘이다:
|
|
3582
|
+
* 한 workspace 의 실패가 다른 workspace 를 세우지 않고, 기존 lease·cursor·dedupe 경로를
|
|
3583
|
+
* 한 줄도 바꾸지 않고 그대로 쓴다.
|
|
3584
|
+
*
|
|
3585
|
+
* DORMANT 는 건너뛴다. 살아 있는 checkout 이 없는 자리를 대신해 외부에 묻지 않는다.
|
|
3586
|
+
*/
|
|
3587
|
+
async function runRuntimeTickAll(values) {
|
|
3588
|
+
// 기계 수준 lease. workspace lease 와 다른 소유 영역이다 (설계 §5.1) — 저쪽은 회차
|
|
3589
|
+
// 하나를, 이쪽은 이 기계의 runtime 을 직렬화한다. 등록된 서비스와 사람이 친 명령이
|
|
3590
|
+
// 겹치면 늦게 온 쪽이 조용히 물러난다 (C-12 불변식 ⑥).
|
|
3591
|
+
const interval = serviceInterval(values) * 1000;
|
|
3592
|
+
const lease = new RuntimeLease({
|
|
3593
|
+
scope: fileScope(join(ascHome(), 'runtime-lease.json')),
|
|
3594
|
+
owner: `${process.pid}@${hostname()}`,
|
|
3595
|
+
staleMs: staleAfter(interval),
|
|
3596
|
+
});
|
|
3597
|
+
if (!(await lease.acquire())) {
|
|
3598
|
+
const held = await lease.read();
|
|
3599
|
+
const detail = held.kind === 'HELD' ? ` (pid ${held.record.pid})` : '';
|
|
3600
|
+
if (values.json)
|
|
3601
|
+
console.log(JSON.stringify({ ran: [], skipped: [], busy: true }, null, 2));
|
|
3602
|
+
else
|
|
3603
|
+
console.log(`Another machine-wide pass is already running${detail} — leaving it to that one.`);
|
|
3604
|
+
return 0;
|
|
3605
|
+
}
|
|
3606
|
+
try {
|
|
3607
|
+
return await tickAllWorkspaces(values, lease);
|
|
3608
|
+
}
|
|
3609
|
+
finally {
|
|
3610
|
+
await lease.release();
|
|
3611
|
+
}
|
|
3612
|
+
}
|
|
3613
|
+
async function tickAllWorkspaces(values, lease) {
|
|
3614
|
+
const views = await machineWorkspaces();
|
|
3615
|
+
const due = dueWorkspaces(views);
|
|
3616
|
+
const skipped = views.filter((view) => view.health !== 'ACTIVE');
|
|
3617
|
+
const results = [];
|
|
3618
|
+
for (const workspace of due) {
|
|
3619
|
+
const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), 'runtime', 'tick'], {
|
|
3620
|
+
cwd: workspace.cwd,
|
|
3621
|
+
stdio: values.json ? 'ignore' : 'inherit',
|
|
3622
|
+
env: process.env,
|
|
3623
|
+
});
|
|
3624
|
+
results.push({ workspaceId: workspace.workspaceId, code: child.status ?? 1 });
|
|
3625
|
+
// 회차가 길어져도 이 기계의 lease 는 살아 있어야 한다 — 갱신하지 않으면 도는 중에
|
|
3626
|
+
// 죽은 것으로 보이고 두 번째 프로세스가 끼어든다.
|
|
3627
|
+
await lease.renew();
|
|
3628
|
+
}
|
|
3629
|
+
if (values.json) {
|
|
3630
|
+
console.log(JSON.stringify({
|
|
3631
|
+
ran: results,
|
|
3632
|
+
// 건너뛴 것을 조용히 빼지 않는다 — "아무 일도 없었다"와 다른 사실이다
|
|
3633
|
+
skipped: skipped.map((view) => ({ workspaceId: view.workspaceId, health: view.health })),
|
|
3634
|
+
}, null, 2));
|
|
3635
|
+
}
|
|
3636
|
+
else {
|
|
3637
|
+
for (const result of results)
|
|
3638
|
+
console.log(`${result.workspaceId}: pass exited ${result.code}`);
|
|
3639
|
+
for (const view of skipped)
|
|
3640
|
+
console.log(`${view.workspaceId}: ${view.health} — not observed this pass`);
|
|
3641
|
+
}
|
|
3642
|
+
// 한 workspace 가 실패해도 회차 자체는 성공이다. 다음 회차가 다시 본다.
|
|
3643
|
+
return 0;
|
|
3106
3644
|
}
|
|
3107
3645
|
/**
|
|
3108
3646
|
* 상시 Runtime (C-12). 대화를 켜 두지 않고 **상태를 지속시키고 계산을 짧게 돌린다.**
|
|
@@ -3111,7 +3649,7 @@ async function buildMonitorEngine(values, store, resolved) {
|
|
|
3111
3649
|
* 재기동해도 cursor·lease·observation이 저장소에 있으므로 같은 사건을 다시 만들지 않는다.
|
|
3112
3650
|
*/
|
|
3113
3651
|
async function runRuntime(command, values, store, renderer, resolved) {
|
|
3114
|
-
if (command !== 'start' && command !== 'tick') {
|
|
3652
|
+
if (command !== 'start' && command !== 'tick' && command !== 'stop') {
|
|
3115
3653
|
console.error(`Unknown runtime command: ${command ?? '(none)'}\n\n${USAGE}`);
|
|
3116
3654
|
return 2;
|
|
3117
3655
|
}
|
|
@@ -3119,10 +3657,17 @@ async function runRuntime(command, values, store, renderer, resolved) {
|
|
|
3119
3657
|
console.error('This only runs inside an attached project. Run `asc init --profile <id>` first.');
|
|
3120
3658
|
return 2;
|
|
3121
3659
|
}
|
|
3122
|
-
|
|
3660
|
+
// 멈추는 것은 감시를 조립하지 않아도 된다 — 외부 자격이 상해 있어도 끌 수 있어야 한다.
|
|
3661
|
+
if (command === 'stop')
|
|
3662
|
+
return stopBackground(store);
|
|
3663
|
+
// 떨어져 나가는 것은 감시를 조립하기 **전에** 한다. 부모가 외부를 한 번 치고 나서
|
|
3664
|
+
// 자식이 또 치면 같은 회차를 두 번 부르는 셈이다.
|
|
3665
|
+
if (command === 'start' && values.detach)
|
|
3666
|
+
return detachRuntime(values);
|
|
3667
|
+
const built = await buildMonitorEngines(values, store, resolved);
|
|
3123
3668
|
if (!built.ok)
|
|
3124
3669
|
return built.code;
|
|
3125
|
-
const {
|
|
3670
|
+
const { channels } = built;
|
|
3126
3671
|
const minutes = (value, fallback) => value === undefined ? fallback * 60_000 : Number(value) * 60_000;
|
|
3127
3672
|
// 주기는 Core 상수가 아니다 (C-12 불변식 ③). 명령줄이 정하고, 기본값은 여기 산다.
|
|
3128
3673
|
const schedule = {
|
|
@@ -3137,33 +3682,46 @@ async function runRuntime(command, values, store, renderer, resolved) {
|
|
|
3137
3682
|
log: (line) => console.log(line),
|
|
3138
3683
|
lastRunAt: {
|
|
3139
3684
|
read: async () => {
|
|
3140
|
-
const raw = await scope.get(
|
|
3685
|
+
const raw = await scope.get(LAST_RUN_KEY);
|
|
3141
3686
|
return raw ? JSON.parse(raw) : {};
|
|
3142
3687
|
},
|
|
3143
3688
|
write: async (kind, at) => {
|
|
3144
|
-
const raw = await scope.get(
|
|
3689
|
+
const raw = await scope.get(LAST_RUN_KEY);
|
|
3145
3690
|
const current = raw ? JSON.parse(raw) : {};
|
|
3146
|
-
await scope.set(
|
|
3691
|
+
await scope.set(LAST_RUN_KEY, JSON.stringify({ ...current, [kind]: at }));
|
|
3147
3692
|
},
|
|
3148
3693
|
},
|
|
3149
3694
|
actions: {
|
|
3695
|
+
// 채널마다 돈다. 한 채널이 터져도 나머지는 계속한다 — 자격 하나가 상했다고
|
|
3696
|
+
// 다른 통로까지 조용해지면 그것이 "변경 없음"으로 읽힌다 (C-12 불변식 ⑫).
|
|
3150
3697
|
delta: async () => {
|
|
3151
|
-
const
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3698
|
+
for (const channel of channels) {
|
|
3699
|
+
const outcome = await channel.engine.scan().catch((error) => {
|
|
3700
|
+
console.error(` [${channel.label}] scan failed — ${String(error)}`);
|
|
3701
|
+
return null;
|
|
3702
|
+
});
|
|
3703
|
+
if (!outcome || outcome.skipped)
|
|
3704
|
+
continue;
|
|
3705
|
+
console.log(` [${channel.label}] detected ${outcome.detected} · logged ${outcome.logged} · packets ${outcome.packets.length}`);
|
|
3706
|
+
}
|
|
3155
3707
|
},
|
|
3156
3708
|
reconcile: async () => {
|
|
3157
|
-
const
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3709
|
+
for (const channel of channels) {
|
|
3710
|
+
const sweep = await channel.engine.reconcile().catch((error) => {
|
|
3711
|
+
console.error(` [${channel.label}] reconcile failed — ${String(error)}`);
|
|
3712
|
+
return null;
|
|
3713
|
+
});
|
|
3714
|
+
if (sweep && !sweep.skipped && !sweep.complete) {
|
|
3715
|
+
console.error(` [${channel.label}] reconcile did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}`);
|
|
3716
|
+
}
|
|
3161
3717
|
}
|
|
3162
3718
|
},
|
|
3163
3719
|
census: async () => {
|
|
3164
|
-
const
|
|
3165
|
-
|
|
3166
|
-
|
|
3720
|
+
for (const channel of channels) {
|
|
3721
|
+
const sweep = await channel.engine.census().catch(() => null);
|
|
3722
|
+
if (sweep && !sweep.skipped && sweep.missing.length > 0) {
|
|
3723
|
+
console.log(` [${channel.label}] ${sweep.missing.length} known items absent from this listing`);
|
|
3724
|
+
}
|
|
3167
3725
|
}
|
|
3168
3726
|
},
|
|
3169
3727
|
digest: async () => {
|
|
@@ -3173,20 +3731,128 @@ async function runRuntime(command, values, store, renderer, resolved) {
|
|
|
3173
3731
|
},
|
|
3174
3732
|
},
|
|
3175
3733
|
});
|
|
3734
|
+
const intervalMs = minutes(values['interval-min'], 1);
|
|
3735
|
+
// 회수 기준은 주기가 정한다 — 주기보다 짧은 만료는 자기 lease를 죽은 것으로 읽는다.
|
|
3736
|
+
const staleMs = staleAfter(intervalMs);
|
|
3737
|
+
const lease = new RuntimeLease({ scope, owner: `${process.pid}@${hostname()}`, staleMs });
|
|
3738
|
+
// cron이 부르는 tick과 떨어져 나간 루프가 겹쳐 돌면 같은 회차를 둘이 한다.
|
|
3739
|
+
// 같은 문을 지나게 해서 하나만 돌린다 (C-12 불변식 ⑥).
|
|
3740
|
+
if (!(await lease.acquire())) {
|
|
3741
|
+
const held = await lease.read();
|
|
3742
|
+
console.log(held.kind === 'HELD'
|
|
3743
|
+
? `Another runtime already holds this workspace (pid ${held.record.pid}) — leaving it to that one.`
|
|
3744
|
+
: 'Another runtime already holds this workspace — leaving it to that one.');
|
|
3745
|
+
return 0;
|
|
3746
|
+
}
|
|
3176
3747
|
if (command === 'tick') {
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3748
|
+
try {
|
|
3749
|
+
const outcome = await orchestrator.tick();
|
|
3750
|
+
console.log(renderTick(outcome));
|
|
3751
|
+
return outcome.failures.length > 0 ? 1 : 0;
|
|
3752
|
+
}
|
|
3753
|
+
finally {
|
|
3754
|
+
await lease.release();
|
|
3755
|
+
}
|
|
3180
3756
|
}
|
|
3181
|
-
const intervalMs = minutes(values['interval-min'], 1);
|
|
3182
3757
|
console.log(`Always-on runtime started — one pass every ${intervalMs / 60_000} min. Ctrl+C to stop.`);
|
|
3758
|
+
// 자는 동안에도 멈출 수 있어야 한다. 깨우지 않으면 `runtime stop` 이 한 주기를 통째로
|
|
3759
|
+
// 기다리고, 주기가 30분이면 30분 동안 "멈추는 중"이 된다.
|
|
3760
|
+
let wake = null;
|
|
3183
3761
|
const stop = () => {
|
|
3184
3762
|
console.log('Stopping — a pass still running is carried to the next start by its lease.');
|
|
3185
3763
|
orchestrator.stop();
|
|
3764
|
+
wake?.();
|
|
3186
3765
|
};
|
|
3187
3766
|
process.once('SIGINT', stop);
|
|
3188
3767
|
process.once('SIGTERM', stop);
|
|
3189
|
-
|
|
3768
|
+
try {
|
|
3769
|
+
await orchestrator.run(intervalMs, async (ms) => {
|
|
3770
|
+
// 회차마다 살아 있다고 말한다. 밀려났으면 조용히 물러난다 — 두 루프가 같은
|
|
3771
|
+
// workspace를 갈아 대는 것보다 하나가 도는 편이 낫다.
|
|
3772
|
+
if (!(await lease.renew())) {
|
|
3773
|
+
console.log('Another runtime took this workspace — stopping.');
|
|
3774
|
+
orchestrator.stop();
|
|
3775
|
+
return;
|
|
3776
|
+
}
|
|
3777
|
+
await new Promise((resolve) => {
|
|
3778
|
+
// **unref 하지 않는다.** 예전에는 여기서 타이머를 unref 했는데, 그러면 이벤트
|
|
3779
|
+
// 루프를 붙잡는 것이 아무것도 없어 첫 잠에서 프로세스가 끝난다 — Node 는
|
|
3780
|
+
// `unsettled top-level await` 로 exit 13 을 낸다. 상시로 돌아야 하는 루프가
|
|
3781
|
+
// 조용히 한 회차만 돌고 죽던 원인이다. 멈출 때는 아래 wake 가 타이머를 지운다.
|
|
3782
|
+
const timer = setTimeout(resolve, ms);
|
|
3783
|
+
wake = () => {
|
|
3784
|
+
clearTimeout(timer);
|
|
3785
|
+
resolve();
|
|
3786
|
+
};
|
|
3787
|
+
});
|
|
3788
|
+
wake = null;
|
|
3789
|
+
});
|
|
3790
|
+
}
|
|
3791
|
+
finally {
|
|
3792
|
+
await lease.release();
|
|
3793
|
+
}
|
|
3794
|
+
return 0;
|
|
3795
|
+
}
|
|
3796
|
+
/**
|
|
3797
|
+
* 이 터미널이 닫혀도 계속 보게 한다 (C-12 §0).
|
|
3798
|
+
*
|
|
3799
|
+
* scheduler 제품을 설치하지 않는다 (불변식 ④) — 같은 명령을 떨어져 나간 프로세스로 다시
|
|
3800
|
+
* 띄울 뿐이다. cron·launchd·Task Scheduler로 `asc runtime tick` 을 부르는 길도 그대로
|
|
3801
|
+
* 열려 있고, 둘은 같은 lease를 지나므로 겹쳐 돌지 않는다.
|
|
3802
|
+
*/
|
|
3803
|
+
async function detachRuntime(values) {
|
|
3804
|
+
// 지금 프로세스가 받은 인자를 그대로 쓰되 --detach 만 뺀다. 옵션을 다시 조립하면
|
|
3805
|
+
// 언젠가 부모와 자식이 다른 주기로 돌게 된다.
|
|
3806
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--detach');
|
|
3807
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...args], {
|
|
3808
|
+
detached: true,
|
|
3809
|
+
stdio: 'ignore',
|
|
3810
|
+
cwd: process.cwd(),
|
|
3811
|
+
env: process.env,
|
|
3812
|
+
});
|
|
3813
|
+
child.unref();
|
|
3814
|
+
if (child.pid === undefined) {
|
|
3815
|
+
console.error('Could not start a background runtime.');
|
|
3816
|
+
return 1;
|
|
3817
|
+
}
|
|
3818
|
+
console.log(`Background runtime started (pid ${child.pid}). \`asc runtime status\` says whether it is observing.`);
|
|
3819
|
+
console.log('`asc runtime stop` ends it.');
|
|
3820
|
+
void values;
|
|
3821
|
+
return 0;
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3824
|
+
* 도는 runtime에게 끝내라고 한다.
|
|
3825
|
+
*
|
|
3826
|
+
* **lease를 지우는 것으로 멈추지 않는다** — 그러면 프로세스는 살아 있는데 아무도 그것을
|
|
3827
|
+
* 모르는 상태가 된다. 신호를 보내고, 그 프로세스가 자기 lease를 놓게 한다.
|
|
3828
|
+
*/
|
|
3829
|
+
async function stopBackground(store) {
|
|
3830
|
+
const scope = store.scope('runtime');
|
|
3831
|
+
const state = await new RuntimeLease({ scope, owner: '(reader)' }).read();
|
|
3832
|
+
if (state.kind === 'FREE') {
|
|
3833
|
+
console.log('No background runtime is registered here.');
|
|
3834
|
+
return 0;
|
|
3835
|
+
}
|
|
3836
|
+
if (state.kind === 'STALE') {
|
|
3837
|
+
// 죽은 프로세스가 남긴 기록이다. 신호를 보낼 곳이 없으므로 기록만 치운다.
|
|
3838
|
+
await scope.delete('runtime-lease');
|
|
3839
|
+
console.log(`Cleared a stale lease from pid ${state.record.pid} — nothing was running.`);
|
|
3840
|
+
return 0;
|
|
3841
|
+
}
|
|
3842
|
+
try {
|
|
3843
|
+
process.kill(state.record.pid, 'SIGTERM');
|
|
3844
|
+
}
|
|
3845
|
+
catch (error) {
|
|
3846
|
+
// 이미 죽은 프로세스다. 기록만 남았으므로 치운다 — 못 지우면 그 사실을 말한다.
|
|
3847
|
+
if (error.code !== 'ESRCH') {
|
|
3848
|
+
console.error(`Could not signal pid ${state.record.pid}: ${String(error)}`);
|
|
3849
|
+
return 1;
|
|
3850
|
+
}
|
|
3851
|
+
await scope.delete('runtime-lease');
|
|
3852
|
+
console.log(`pid ${state.record.pid} was already gone — cleared its lease.`);
|
|
3853
|
+
return 0;
|
|
3854
|
+
}
|
|
3855
|
+
console.log(`Asked pid ${state.record.pid} to finish its pass and stop.`);
|
|
3190
3856
|
return 0;
|
|
3191
3857
|
}
|
|
3192
3858
|
/**
|
|
@@ -3423,6 +4089,58 @@ async function runFreeze(group, command, values, store) {
|
|
|
3423
4089
|
${USAGE}`);
|
|
3424
4090
|
return 2;
|
|
3425
4091
|
}
|
|
4092
|
+
/** 판정 결과에서 workspace 신원만 꺼낸다. 갈래마다 모양이 달라 한 곳에서 접는다. */
|
|
4093
|
+
function workspaceOf(resolution) {
|
|
4094
|
+
return resolution.kind === 'REGISTERED' || resolution.kind === 'LINKED_WORKTREE'
|
|
4095
|
+
? { workspaceId: resolution.workspaceId, locator: resolution.locator }
|
|
4096
|
+
: null;
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* Host 세션이 열렸다 (C-12 §4). **이 명령은 실패하지 않는다.**
|
|
4100
|
+
*
|
|
4101
|
+
* Host lifecycle이 부르는 자리라 세 가지를 지킨다:
|
|
4102
|
+
*
|
|
4103
|
+
* 붙지 않은 자리 아무 말도 하지 않고 exit 0 — 남의 프로젝트를 방해하지 않는다
|
|
4104
|
+
* 상태를 못 읽는 자리 왜인지 말한다 — 조용한 빈 화면을 주지 않는다 (불변식 ⑰)
|
|
4105
|
+
* 미등록 linked worktree 공용 resolver가 풀고 등록까지 한다 (C-11 §1.3)
|
|
4106
|
+
*
|
|
4107
|
+
* **workspace 신원을 여기서 다시 구현하지 않는다.** `resolveRoot` 하나만 부른다.
|
|
4108
|
+
*/
|
|
4109
|
+
async function runFrontOpen(values) {
|
|
4110
|
+
const emit = async (opening) => {
|
|
4111
|
+
const lines = frontOpeningLines(opening);
|
|
4112
|
+
if (values.json) {
|
|
4113
|
+
// payload는 Claude Code가 SessionStart에서 읽는 봉투다. 다른 host는 lines를 쓴다.
|
|
4114
|
+
console.log(JSON.stringify({ kind: opening.kind, lines, payload: sessionStartPayload(lines) }, null, 2));
|
|
4115
|
+
return 0;
|
|
4116
|
+
}
|
|
4117
|
+
for (const line of lines)
|
|
4118
|
+
console.log(line);
|
|
4119
|
+
return 0;
|
|
4120
|
+
};
|
|
4121
|
+
const resolution = await resolveRoot(process.cwd(), values.root).catch(() => null);
|
|
4122
|
+
const workspace = resolution ? workspaceOf(resolution) : null;
|
|
4123
|
+
// EXPLICIT·PROJECT_LOCAL 은 workspace id가 없지만 붙은 자리다 — 신원 없이도 상태는 읽는다.
|
|
4124
|
+
const root = resolution && resolution.kind !== 'UNRESOLVED' ? resolution.root : null;
|
|
4125
|
+
if (!root)
|
|
4126
|
+
return emit({ kind: 'NOT_ASC' });
|
|
4127
|
+
const store = new MarkdownStateStore(root);
|
|
4128
|
+
return emit(await openFront({
|
|
4129
|
+
// 신원을 모르는 자리(project-local)라도 붙은 것은 붙은 것이다
|
|
4130
|
+
workspace: workspace ?? { workspaceId: '(project-local)', locator: root },
|
|
4131
|
+
restore: async () => {
|
|
4132
|
+
const scope = await activeMonitorScope(store);
|
|
4133
|
+
return restoreFront({
|
|
4134
|
+
store,
|
|
4135
|
+
pending: await new LocalOperator({ store }).list({}),
|
|
4136
|
+
escalations: await escalationLedger(store).pending(),
|
|
4137
|
+
health: evaluateHealth(await new CoverageLedger(store.scope(scope)).health(), new Date().toISOString(), HEALTH_THRESHOLDS),
|
|
4138
|
+
...(workspace ? { workspace } : {}),
|
|
4139
|
+
bindings: claudeBindings(store),
|
|
4140
|
+
});
|
|
4141
|
+
},
|
|
4142
|
+
}));
|
|
4143
|
+
}
|
|
3426
4144
|
async function runFront(command, values, store, root) {
|
|
3427
4145
|
if (command !== undefined && command !== 'status') {
|
|
3428
4146
|
console.error(`Unknown front command: ${command}
|
|
@@ -3431,8 +4149,9 @@ ${USAGE}`);
|
|
|
3431
4149
|
return 2;
|
|
3432
4150
|
}
|
|
3433
4151
|
const scope = await activeMonitorScope(store);
|
|
3434
|
-
|
|
3435
|
-
|
|
4152
|
+
// **index를 직접 뒤지지 않는다.** 모든 root 판정이 지나는 같은 문을 쓴다 — 그래야
|
|
4153
|
+
// 미등록 linked worktree도 같은 workspace로 풀리고, 그 자리가 등록까지 된다 (C-11 §1.3).
|
|
4154
|
+
const located = workspaceOf(await resolveRoot(process.cwd(), values.root));
|
|
3436
4155
|
const state = await restoreFront({
|
|
3437
4156
|
store,
|
|
3438
4157
|
pending: await new LocalOperator({ store }).list({}),
|
|
@@ -3466,51 +4185,71 @@ async function runMonitor(command, values, store, renderer, resolved) {
|
|
|
3466
4185
|
console.error(`Monitoring is not running — ${readJudgment.detail}`);
|
|
3467
4186
|
return 2;
|
|
3468
4187
|
}
|
|
3469
|
-
const built = await
|
|
4188
|
+
const built = await buildMonitorEngines(values, store, resolved);
|
|
3470
4189
|
if (!built.ok)
|
|
3471
4190
|
return built.code;
|
|
3472
|
-
const {
|
|
4191
|
+
const { channels, repo } = built;
|
|
4192
|
+
// 채널마다 자기 회차를 돈다. **한 채널의 실패가 다른 채널을 세우지 않는다** — 코드 쪽이
|
|
4193
|
+
// 자격을 잃었다고 작업 항목 쪽까지 못 보게 되면, 그것이 곧 "변화 없음"으로 읽힌다.
|
|
4194
|
+
let worst = 0;
|
|
3473
4195
|
// 어디까지 확인했는지 보여주기만 한다. 판정하지도, 무엇을 고치지도 않는다.
|
|
3474
4196
|
if (command === 'status') {
|
|
3475
|
-
const health = await engine.health();
|
|
3476
|
-
console.log(values.json ? JSON.stringify(health, null, 2) : renderHealth(repo, health).join('\n'));
|
|
3477
|
-
return 0;
|
|
3478
|
-
}
|
|
3479
|
-
if (command === 'reconcile' || command === 'census') {
|
|
3480
|
-
const sweep = command === 'reconcile' ? await engine.reconcile() : await engine.census();
|
|
3481
|
-
if (sweep.skipped) {
|
|
3482
|
-
console.log('Another run is in progress. The next pass will pick it up.');
|
|
3483
|
-
return 0;
|
|
3484
|
-
}
|
|
3485
4197
|
if (values.json) {
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
console.log(' (whether that is deletion, permissions, visibility or a query error is not judged here — a person looks)');
|
|
4198
|
+
const perChannel = [];
|
|
4199
|
+
for (const channel of channels) {
|
|
4200
|
+
perChannel.push({ channel: channel.label, source: channel.sourceId, health: await channel.engine.health() });
|
|
4201
|
+
}
|
|
4202
|
+
console.log(JSON.stringify({ repo, channels: perChannel }, null, 2));
|
|
4203
|
+
return 0;
|
|
3493
4204
|
}
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
console.
|
|
3497
|
-
return 1;
|
|
4205
|
+
for (const channel of channels) {
|
|
4206
|
+
console.log(`[${channel.label}]`);
|
|
4207
|
+
console.log(renderHealth(repo, await channel.engine.health()).join('\n'));
|
|
3498
4208
|
}
|
|
3499
4209
|
return 0;
|
|
3500
4210
|
}
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
4211
|
+
if (command === 'reconcile' || command === 'census') {
|
|
4212
|
+
for (const channel of channels) {
|
|
4213
|
+
const sweep = command === 'reconcile' ? await channel.engine.reconcile() : await channel.engine.census();
|
|
4214
|
+
if (sweep.skipped) {
|
|
4215
|
+
console.log(`[${channel.label}] another run is in progress. The next pass will pick it up.`);
|
|
4216
|
+
continue;
|
|
4217
|
+
}
|
|
4218
|
+
if (values.json) {
|
|
4219
|
+
console.log(JSON.stringify({ channel: channel.label, ...sweep }, null, 2));
|
|
4220
|
+
}
|
|
4221
|
+
else {
|
|
4222
|
+
console.log(`[${channel.label}] ${sweep.kind}: ${sweep.seen} listed · ${sweep.changed} changed · ${sweep.packets.length} packets`);
|
|
4223
|
+
if (sweep.missing.length > 0) {
|
|
4224
|
+
console.log(` ${sweep.missing.length} known items absent from this listing: ${sweep.missing.join(', ')}`);
|
|
4225
|
+
console.log(' (whether that is deletion, permissions, visibility or a query error is not judged here — a person looks)');
|
|
4226
|
+
}
|
|
4227
|
+
}
|
|
4228
|
+
if (!sweep.complete) {
|
|
4229
|
+
// 완주하지 못한 회차를 성공으로 보이면 "확인했다"는 거짓말이 된다
|
|
4230
|
+
console.error(`[${channel.label}] the listing did not complete${sweep.detail ? ` — ${sweep.detail}` : ''}. This pass makes no judgement about disappearances.`);
|
|
4231
|
+
worst = 1;
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
return worst;
|
|
3505
4235
|
}
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
4236
|
+
let packets = 0;
|
|
4237
|
+
for (const channel of channels) {
|
|
4238
|
+
const outcome = await channel.engine.scan();
|
|
4239
|
+
if (outcome.skipped) {
|
|
4240
|
+
console.log(`[${channel.label}] another scan is in progress. The next pass will pick it up.`);
|
|
4241
|
+
continue;
|
|
4242
|
+
}
|
|
4243
|
+
console.log(`[${channel.label}] 감지 ${outcome.detected} · 중복 ${outcome.duplicates} · 기록 ${outcome.logged} · ` +
|
|
4244
|
+
`보고서 ${outcome.packets.length} · 재시도 ${outcome.retries.length}`);
|
|
4245
|
+
packets += outcome.packets.length;
|
|
4246
|
+
}
|
|
4247
|
+
if (packets > 0) {
|
|
3509
4248
|
const operator = new LocalOperator({ store });
|
|
3510
4249
|
console.log();
|
|
3511
4250
|
console.log(renderer.renderList(await operator.list()).text);
|
|
3512
4251
|
}
|
|
3513
|
-
return
|
|
4252
|
+
return worst;
|
|
3514
4253
|
}
|
|
3515
4254
|
/**
|
|
3516
4255
|
* 외부로 내보내는 경로. 승인과 분리돼 있는 이유는 OM §11.8 — 승인은 내용에 동의한
|