@asc-agent/runtime 0.5.4 → 0.6.1

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 CHANGED
@@ -7,7 +7,7 @@ proceed-by-default, escalation, audit, the external-write guard, host integratio
7
7
  here.
8
8
 
9
9
  ```bash
10
- npm install -g @asc-agent/runtime@0.5.4
10
+ npm install -g @asc-agent/runtime@0.6.1
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -84,6 +84,27 @@ The user does not need to know this sequence. This is the one the skill follows.
84
84
  canonical source are all a person's call, and \`setup status\` already states the reason
85
85
  and the remedy.
86
86
 
87
+ ## "Update ASC" — one command per product, no questions
88
+
89
+ Each product updates itself. ASC never updates JAM and JAM never updates ASC; the person
90
+ asking for both is what puts them in one turn.
91
+
92
+ \`\`\`text
93
+ jam update → verify it answered
94
+ asc update → verify it answered
95
+ \`\`\`
96
+
97
+ Order matters that way round: ASC's work channel reads JAM, so JAM lands first. Neither
98
+ command asks anything on the normal path, and neither re-runs setup — an update replaces
99
+ the executable and leaves the workspace, profile, bindings, canonical source, sessions and
100
+ evidence exactly as they were. Do not reach for \`setup apply\` to update; that is the path
101
+ that re-infers all of it.
102
+
103
+ Read the state before acting when the person asked whether an update is needed:
104
+ \`asc update check --json\` and \`jam update check --json\` change nothing. \`UNKNOWN\` means
105
+ the registry could not be asked — it does not mean up to date, and it is not a failure to
106
+ report as one.
107
+
87
108
  ## Procedure
88
109
 
89
110
  1. Run \`asc proceed --json\` (add \`--session <S-ID>\` to name a session).
package/dist/cli/asc.js CHANGED
@@ -12,9 +12,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
14
  import { homedir, hostname, userInfo } from 'node:os';
15
- import { basename, dirname, join, resolve } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+ import { basename, dirname, join, relative, resolve } from 'node:path';
16
17
  import { MINIMUM_NODE_MAJOR, checkNodeRuntime, reexecWithCandidate, } from "../core/distribution/node-runtime.js";
17
18
  import { RELEASE_VERSION, RUNTIME_PACKAGE } from "../core/distribution/release.js";
19
+ import { planUpdate, requiredMajorFrom, updateLine } from "../core/distribution/update.js";
18
20
  import { GitHubClient, discoverToken } from "../adapters/github/client.js";
19
21
  import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
20
22
  import { GitHubEventSource } from "../adapters/github/event-source.js";
@@ -133,6 +135,10 @@ const USAGE = `asc — Agent Session Control
133
135
  asc runtime status [--json] # which build is in use, and whether it observes
134
136
  asc runtime use package
135
137
  asc runtime use development <checkout> # run a built checkout instead
138
+
139
+ asc update # install the newest release and verify it
140
+ asc update check [--json] # what is installed, what is published
141
+ asc update plan [--json] # what would be done, changing nothing
136
142
  asc front [status] [--json]
137
143
  asc front open [--json] # a host session opened here — what is waiting
138
144
  asc escalate open <S-ID> --predicate <p>... --question <t> --blocked <node>...
@@ -701,12 +707,16 @@ async function runParsedCommand(values, positionals, entry, argv) {
701
707
  // 사람이 갇힌다.
702
708
  // 기계 수준 명령은 **프로젝트와 무관하다.** 붙지 않은 자리에서도 답해야 하고,
703
709
  // 등록된 서비스는 어느 프로젝트 안에서 도는 것이 아니다 (설계 §4.1).
704
- const machineLevelRuntime = group === 'runtime' &&
705
- (command === 'use' ||
706
- command === 'status' ||
707
- command === 'list' ||
708
- command === 'service' ||
709
- (command === 'tick' && Boolean(values.all)));
710
+ // 업데이트는 **갈아 끼우려는 build 로 넘어가면 안 된다** — 넘어가면 구본이 자기를
711
+ // 교체하는 셈이고, 교체 도중 그 파일들이 사라진다. 붙지 않은 자리에서도 답해야 하므로
712
+ // 기계 수준 명령과 같은 자리에 둔다.
713
+ const machineLevelRuntime = group === 'update' ||
714
+ (group === 'runtime' &&
715
+ (command === 'use' ||
716
+ command === 'status' ||
717
+ command === 'list' ||
718
+ command === 'service' ||
719
+ (command === 'tick' && Boolean(values.all))));
710
720
  if (!machineLevelRuntime) {
711
721
  const redispatched = await redispatchIfNeeded(argv);
712
722
  if (redispatched !== null)
@@ -729,6 +739,9 @@ async function runParsedCommand(values, positionals, entry, argv) {
729
739
  return runRuntimeTickAll(values);
730
740
  if (group === 'host')
731
741
  return runHost(command, positionals[2], positionals[3], values);
742
+ // 갈아 끼우는 일은 붙은 프로젝트와 무관하다 — 어느 자리에서 쳐도 같은 답이어야 한다.
743
+ if (group === 'update')
744
+ return runUpdate(command, values);
732
745
  // setup은 **붙기 전에도** 답을 줘야 한다. 아래 discoverRoot 실패는 exit 2로 끊는데,
733
746
  // 그러면 "아직 안 붙었다"를 확인하려고 부른 명령이 안 붙었다는 이유로 죽는다.
734
747
  if (group === 'setup')
@@ -2043,8 +2056,16 @@ async function checkBootstrap(root) {
2043
2056
  adapters: ADAPTER_VERSIONS,
2044
2057
  ascVersion: ASC_VERSION,
2045
2058
  });
2046
- if (outcome.ok)
2059
+ if (outcome.ok) {
2060
+ // 판번호만 낡은 lock 은 멈출 이유가 아니다. 다만 조용히 지나가지도 않는다 —
2061
+ // 다음 재고정 때 따라온다는 것을 여기서 한 번 말한다.
2062
+ if (outcome.staleLock) {
2063
+ const moved = outcome.staleLock.find((drift) => drift.field === 'ascCore.version');
2064
+ if (moved)
2065
+ console.error(`(profile.lock was written by ASC ${moved.locked}; this is ${moved.current}. \`asc profile resolve --write\` records it.)`);
2066
+ }
2047
2067
  return { code: 0, runtime: outcome.runtime };
2068
+ }
2048
2069
  // 아직 붙이지 않았으면 설정 없이 도는 경로만 쓰는 것이므로 막지 않는다
2049
2070
  if (outcome.reason === 'NOT_ATTACHED')
2050
2071
  return { code: 0 };
@@ -3920,6 +3941,205 @@ function serviceEnvironment(node) {
3920
3941
  return { environment: { PATH: path } };
3921
3942
  }
3922
3943
  const serviceInterval = (values) => values['interval-min'] === undefined ? 5 * 60 : Math.max(60, Number(values['interval-min']) * 60);
3944
+ /**
3945
+ * `asc update` — 돌던 것을 잃지 않고 갈아 끼운다 (C-14 §3).
3946
+ *
3947
+ * 이 명령이 있는 이유는 실측이다. 한 라운드에 설치본을 다섯 번 갈아 끼웠고, 매번 사람이
3948
+ * 구본을 먼저 지우고 `setup` 을 통째로 다시 돌렸다. 뒤쪽이 특히 나쁘다 — `setup` 은
3949
+ * profile·binding·정본을 **다시 추론**하는 경로이고, 업데이트는 이미 정해진 것 위에서
3950
+ * 실행본만 바꾸는 일이다. 그래서 여기서는 그 함수들을 부르지 않는다.
3951
+ *
3952
+ * 순서는 계획이 정하고(`planUpdate`), 여기서는 그대로 실행한다.
3953
+ */
3954
+ async function runUpdate(command, values) {
3955
+ if (command !== undefined && command !== 'check' && command !== 'plan') {
3956
+ console.error(`Unknown update command: ${command}\n\n${USAGE}`);
3957
+ return 2;
3958
+ }
3959
+ const plan = await observeUpdate();
3960
+ if (command === 'check' || command === 'plan') {
3961
+ if (values.json) {
3962
+ console.log(JSON.stringify({ package: RUNTIME_PACKAGE, ...plan }, null, 2));
3963
+ }
3964
+ else {
3965
+ console.log(updateLine(plan));
3966
+ for (const step of plan.steps)
3967
+ console.log(` ${step}`);
3968
+ }
3969
+ // 읽기는 상태를 판정하되 실패로 만들지 않는다 — 물어본 것에는 답한 것이다.
3970
+ return 0;
3971
+ }
3972
+ if (plan.steps.length === 0) {
3973
+ console.log(updateLine(plan));
3974
+ // 못 하는 것과 할 것이 없는 것은 다르다. `CURRENT` 만 성공이다.
3975
+ return plan.state === 'CURRENT' ? 0 : 1;
3976
+ }
3977
+ return applyUpdate(plan, values);
3978
+ }
3979
+ /**
3980
+ * 세상의 사실을 모아 계획에 넘긴다. **판정은 여기서 하지 않는다** (`setup` 과 같은 태도).
3981
+ *
3982
+ * registry 를 못 물으면 그 사실이 그대로 계획에 간다 — 조회 실패를 "최신" 으로 뭉개면
3983
+ * 그 답이 곧 사람이 업데이트를 건너뛰는 근거가 된다.
3984
+ */
3985
+ async function observeUpdate() {
3986
+ const latest = await execText('npm', ['view', RUNTIME_PACKAGE, 'version']);
3987
+ const engines = latest ? await execText('npm', ['view', `${RUNTIME_PACKAGE}@${latest}`, 'engines.node']) : null;
3988
+ const required = requiredMajorFrom(engines ?? undefined);
3989
+ const installed = await detectStableInstall(nodeProcessRunner, latest ?? RELEASE_VERSION);
3990
+ const node = await checkNodeRuntime(nodeRuntimeDeps());
3991
+ return planUpdate({
3992
+ ...(installed.installedVersion ? { installed: installed.installedVersion } : {}),
3993
+ executableVisible: installed.executableVisible,
3994
+ ...(latest ? { latest } : {}),
3995
+ ...(required !== undefined ? { requiredNodeMajor: required } : {}),
3996
+ nodeVersion: process.version,
3997
+ ...(node.ok ? {} : { nodeCandidates: node.candidates }),
3998
+ });
3999
+ }
4000
+ /**
4001
+ * 계획대로 실행한다. **삭제하지 않는다** — npm 전역 설치는 같은 자리를 덮으므로 치울
4002
+ * 구본이 없고, 치울 것이 있다고 적으면 그 단계는 언젠가 지우지 말아야 할 것을 지운다.
4003
+ */
4004
+ async function applyUpdate(plan, values) {
4005
+ const target = plan.to;
4006
+ console.log(updateLine(plan));
4007
+ // 무엇이 바뀌면 안 되는지를 **먼저** 적어 둔다. 업데이트가 건드려도 되는 것은 셋뿐이다 —
4008
+ // 설치본, host 설치물, 기계 등록물. 나머지는 이미 정해진 것이고, 그것을 다시 정하는
4009
+ // 경로(`setup`)를 부르지 않는다는 말은 여기서 증거로 확인된다.
4010
+ const before = await protectedState();
4011
+ const installed = await installStableRuntime(nodeProcessRunner, target);
4012
+ if (!installed.ok) {
4013
+ console.error(`install failed: ${installed.detail ?? '(no detail)'}`);
4014
+ // 설치가 아예 안 됐다. 돌던 것이 그대로 서 있는지는 확인해야 안다 — npm 은 중간에서도
4015
+ // 실패한다.
4016
+ return rollbackUpdate(plan);
4017
+ }
4018
+ // npm 이 화내지 않았다는 것과 그 버전이 실제로 서 있다는 것은 다르다 (C-14 §3.3).
4019
+ const verified = await verifyStableInstall(nodeProcessRunner, target);
4020
+ if (!verified.ok) {
4021
+ console.error(`verify failed: ${verified.remedy ?? verified.state.detail ?? verified.state.status}`);
4022
+ return rollbackUpdate(plan);
4023
+ }
4024
+ console.log(`installed: ${RUNTIME_PACKAGE}@${target}`);
4025
+ let worst = 0;
4026
+ // host 설치물은 버전마다 내용이 바뀐다. 새 runtime 에 낡은 hook 을 남기지 않는다.
4027
+ // **사람이 고친 것은 덮지 않는다** — 그것은 남의 파일이고, 덮으면 그 사람의 수정이
4028
+ // 말없이 사라진다. 남은 것은 말하고, 업데이트 자체는 계속 간다.
4029
+ const host = await install(hostPaths(), undefined, { force: false });
4030
+ for (const path of host.written)
4031
+ console.log(`host: ${path}`);
4032
+ for (const skip of host.skipped) {
4033
+ console.error(`host: ${skip.path} left as it is — ${skip.reason}`);
4034
+ worst = 1;
4035
+ }
4036
+ // 등록물이 낡았으면 지금 형태로 수렴시킨다. 기존 STALE→수렴 경로를 그대로 쓴다.
4037
+ worst = Math.max(worst, await convergeService(values));
4038
+ // 마지막은 언제나 확인이다.
4039
+ const health = await detectStableInstall(nodeProcessRunner, target);
4040
+ if (health.status !== 'CURRENT') {
4041
+ console.error(`health: ${health.detail ?? health.status}`);
4042
+ return 1;
4043
+ }
4044
+ // 상태 불변 — 이 명령의 가장 중요한 계약이다.
4045
+ const changed = diffState(before, await protectedState());
4046
+ if (changed.length > 0) {
4047
+ // 회차가 겹쳐 관측 기록이 늘어난 것일 수도 있다. 어느 쪽이든 **무엇이 달라졌는지
4048
+ // 말한다** — "안 바뀌었다"를 확인 없이 적는 것이 이 계약을 없애는 방식이다.
4049
+ console.error(`state changed during the update (${changed.length}):`);
4050
+ for (const path of changed.slice(0, 10))
4051
+ console.error(` ${path}`);
4052
+ worst = 1;
4053
+ }
4054
+ else {
4055
+ console.log('state: unchanged');
4056
+ }
4057
+ console.log(`asc ${target} is current.`);
4058
+ return worst;
4059
+ }
4060
+ /**
4061
+ * 바뀌면 안 되는 것들. **위치를 새로 정하지 않는다** — 이 machine 의 `~/.asc` 가 그대로
4062
+ * 그 자리이고, 업데이트가 건드려도 되는 셋(설치본·host 설치물·기계 등록물)은 여기 없다.
4063
+ *
4064
+ * 등록물이 회차마다 다시 쓰는 lease·log 는 상태가 아니라 실행 흔적이라 뺀다.
4065
+ */
4066
+ async function protectedState() {
4067
+ const home = ascHome();
4068
+ const state = new Map();
4069
+ const skip = new Set(['runtime-lease.json', 'service.log']);
4070
+ const walk = async (dir) => {
4071
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
4072
+ const full = join(dir, entry.name);
4073
+ if (skip.has(relative(home, full)))
4074
+ continue;
4075
+ if (entry.isDirectory())
4076
+ await walk(full);
4077
+ else
4078
+ state.set(relative(home, full), createHash('sha256').update(await readFile(full).catch(() => Buffer.alloc(0))).digest('hex'));
4079
+ }
4080
+ };
4081
+ await walk(home);
4082
+ return state;
4083
+ }
4084
+ /** 달라진 경로들. 없어진 것도 달라진 것이다. */
4085
+ function diffState(before, after) {
4086
+ const changed = [];
4087
+ for (const [path, digest] of before)
4088
+ if (after.get(path) !== digest)
4089
+ changed.push(path);
4090
+ for (const path of after.keys())
4091
+ if (!before.has(path))
4092
+ changed.push(path);
4093
+ return changed.sort();
4094
+ }
4095
+ /**
4096
+ * 되돌린다. **되돌릴 자리가 있을 때만** — 없던 것으로 되돌릴 수는 없고, 그때는 돌던 것이
4097
+ * 없었다는 사실을 그대로 말한다.
4098
+ */
4099
+ async function rollbackUpdate(plan) {
4100
+ if (!plan.rollbackTo) {
4101
+ console.error('Nothing to roll back to — this machine had no installed runtime before.');
4102
+ return 1;
4103
+ }
4104
+ console.error(`rolling back to ${plan.rollbackTo}`);
4105
+ const back = await installStableRuntime(nodeProcessRunner, plan.rollbackTo);
4106
+ const verified = back.ok ? await verifyStableInstall(nodeProcessRunner, plan.rollbackTo) : null;
4107
+ if (verified?.ok) {
4108
+ console.error(`UPDATE_FAILED_ROLLED_BACK — asc ${plan.rollbackTo} is current again.`);
4109
+ return 1;
4110
+ }
4111
+ // 되돌리기까지 실패했다. 여기서부터는 사람의 자리다 — 숨기지 않는다.
4112
+ console.error(`BROKEN — rollback failed: ${back.detail ?? verified?.remedy ?? '(no detail)'}`);
4113
+ console.error(`Install it directly: npm install -g ${RUNTIME_PACKAGE}@${plan.rollbackTo}`);
4114
+ return 1;
4115
+ }
4116
+ /** 등록물을 지금 실행본으로 수렴시킨다. 등록이 없던 기계에 새로 만들지는 않는다. */
4117
+ async function convergeService(values) {
4118
+ const adapter = serviceAdapter();
4119
+ if (!adapter)
4120
+ return 0;
4121
+ const runtime = await serviceRuntime();
4122
+ if (runtime.kind !== 'STABLE') {
4123
+ console.error(`service: ${serviceRuntimeLine(runtime)}`);
4124
+ return 1;
4125
+ }
4126
+ const wanted = serviceCommand(serviceInterval(values), runtime);
4127
+ const plan = await planPersistentRuntime(adapter, wanted).catch(() => null);
4128
+ if (!plan)
4129
+ return 0;
4130
+ if (plan.action === 'none' || plan.action === 'unsupported') {
4131
+ console.log(`service: ${persistentRuntimeLine(adapter.id, plan)}`);
4132
+ return 0;
4133
+ }
4134
+ // 등록이 없던 기계라면 `install` 이 계획된다 — 업데이트가 등록을 새로 만들지는 않는다.
4135
+ if (plan.action === 'install') {
4136
+ console.log('service: not registered on this machine — leaving it that way (`asc runtime service install`)');
4137
+ return 0;
4138
+ }
4139
+ await adapter.install(wanted);
4140
+ console.log(`service: converged with ${adapter.id}`);
4141
+ return 0;
4142
+ }
3923
4143
  /**
3924
4144
  * `asc runtime service` — 이 기계의 지속 등록 (설계 §4).
3925
4145
  *
@@ -1,9 +1,9 @@
1
1
  export declare const RUNTIME_PACKAGE = "@asc-agent/runtime";
2
2
  export declare const BOOTSTRAP_PACKAGE = "@asc-agent/bootstrap";
3
3
  /** runtime과 bootstrap은 초기 release에서 lockstep이다. */
4
- export declare const RELEASE_VERSION = "0.5.4";
5
- export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.5.4";
6
- export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.5.4";
4
+ export declare const RELEASE_VERSION = "0.6.1";
5
+ export declare const RUNTIME_SPEC = "@asc-agent/runtime@0.6.1";
6
+ export declare const BOOTSTRAP_SPEC = "@asc-agent/bootstrap@0.6.1";
7
7
  /**
8
8
  * 아직 설치되지 않은 machine에서 그대로 실행되는 형태 (C-14 §3.4).
9
9
  *
@@ -9,7 +9,7 @@
9
9
  export const RUNTIME_PACKAGE = '@asc-agent/runtime';
10
10
  export const BOOTSTRAP_PACKAGE = '@asc-agent/bootstrap';
11
11
  /** runtime과 bootstrap은 초기 release에서 lockstep이다. */
12
- export const RELEASE_VERSION = '0.5.4';
12
+ export const RELEASE_VERSION = '0.6.1';
13
13
  export const RUNTIME_SPEC = `${RUNTIME_PACKAGE}@${RELEASE_VERSION}`;
14
14
  export const BOOTSTRAP_SPEC = `${BOOTSTRAP_PACKAGE}@${RELEASE_VERSION}`;
15
15
  /**
@@ -0,0 +1,59 @@
1
+ import { type NodeCandidate } from './node-runtime.ts';
2
+ export type UpdateState =
3
+ /** 설치본이 registry 의 최신과 같다. */
4
+ 'CURRENT' | 'UPDATE_AVAILABLE'
5
+ /** 대상이 요구하는 Node 가 이 기계에 없다 — 설치하지 않는다. */
6
+ | 'INCOMPATIBLE'
7
+ /** 설치는 돼 있는데 실행물이 보이지 않는다. 새 것을 얹기 전에 그 사실을 말한다. */
8
+ | 'BROKEN'
9
+ /** registry 를 물어보지 못했다. **아무 주장도 하지 않는다.** */
10
+ | 'UNKNOWN';
11
+ /**
12
+ * 실행 순서. **지우는 단계가 없다** — 위 주석의 이유다.
13
+ */
14
+ export type UpdateStep = 'install' | 'verify-install'
15
+ /** 버전마다 skill·hook 내용이 바뀐다. 새 runtime 에 낡은 hook 을 남기지 않는다. */
16
+ | 'refresh-host'
17
+ /** 등록물이 낡았으면 지금 형태로 수렴시킨다. 기존 STALE→수렴 경로를 그대로 쓴다. */
18
+ | 'converge-service' | 'verify-health';
19
+ export declare const UPDATE_ORDER: readonly UpdateStep[];
20
+ export type UpdateInput = {
21
+ /** 전역에 실제로 있는 버전. 없으면 설치된 적이 없다. */
22
+ installed?: string;
23
+ /** 그 실행물이 이 프로세스에서 보이는가. */
24
+ executableVisible: boolean;
25
+ /** registry 가 말한 최신. 못 물었으면 없다 — 그때는 아무 판정도 하지 않는다. */
26
+ latest?: string;
27
+ /** 대상이 요구하는 Node 하한. 모르면 이 build 의 하한을 쓴다. */
28
+ requiredNodeMajor?: number;
29
+ /** 지금 이 프로세스의 Node 버전. */
30
+ nodeVersion: string;
31
+ /** 이 기계에서 찾은 다른 Node 들. 지금 것이 낮을 때만 본다. */
32
+ nodeCandidates?: readonly NodeCandidate[];
33
+ };
34
+ export type UpdatePlan = {
35
+ state: UpdateState;
36
+ from?: string;
37
+ to?: string;
38
+ /** 할 일. `INCOMPATIBLE`·`UNKNOWN`·`CURRENT` 에서는 비어 있다. */
39
+ steps: readonly UpdateStep[];
40
+ /**
41
+ * 되돌릴 대상. **설치되어 있던 버전이 있을 때만** 채운다 — 없던 것으로 되돌릴 수는 없다.
42
+ */
43
+ rollbackTo?: string;
44
+ detail?: string;
45
+ };
46
+ /**
47
+ * 무엇을 할 것인가. **아무것도 하지 않는다** — 사실은 호출자가 관측해 넘긴다.
48
+ */
49
+ export declare function planUpdate(input: UpdateInput): UpdatePlan;
50
+ /** 사람이 읽는 한 줄. 왜 그 판정인지가 함께 와야 한다. */
51
+ export declare function updateLine(plan: UpdatePlan): string;
52
+ /**
53
+ * `engines.node` 가 말하는 하한. `">=24"` · `">=24.0.0 <27"` 같은 형태를 읽는다.
54
+ *
55
+ * **못 읽으면 `undefined` 다** — 0 으로 뭉개면 아무 Node 나 통과하고, 큰 수로 뭉개면
56
+ * 멀쩡한 기계가 INCOMPATIBLE 이 된다. 모르는 것은 모른다고 하고 호출자가 이 build 의
57
+ * 하한을 쓴다.
58
+ */
59
+ export declare function requiredMajorFrom(engines: string | undefined): number | undefined;
@@ -0,0 +1,106 @@
1
+ // Update — 갈아 끼우되, 돌던 것을 잃지 않는다 (C-14 §3 의 연장).
2
+ //
3
+ // 이 파일이 있는 이유는 실측이다. 한 라운드에 설치본을 다섯 번 갈아 끼웠고, 매번 사람이
4
+ // 이런 것을 손으로 했다:
5
+ //
6
+ // npm uninstall -g … 구본을 먼저 지운다 — 실패하면 아무것도 안 남는 순서다
7
+ // rm -rf ~/.npm/_npx/* 캐시를 지운다
8
+ // setup apply 를 다시 실행 profile·binding·정본을 **다시 추론**한다
9
+ //
10
+ // 마지막 것이 가장 나쁘다. 업데이트는 설정을 다시 정하는 행위가 아니다. 이미 정해진 것
11
+ // 위에서 실행본만 바꾸는 일이고, 그래서 순서가 정해져 있다:
12
+ //
13
+ // 놓는다 → 선 것을 확인한다 → 갈아 끼운다 → 다시 확인한다
14
+ //
15
+ // 지우는 단계는 없다. npm 전역 설치는 같은 자리를 덮으므로 치울 구본이 없고, 치울 것이
16
+ // 있다고 적으면 그 단계는 언젠가 지우지 말아야 할 것을 지운다.
17
+ //
18
+ // **모르는 것을 최신으로 읽지 않는다.** registry 를 못 물었을 때 "최신입니다" 라고 답하면
19
+ // 그 답이 곧 사람이 업데이트를 건너뛰는 근거가 된다.
20
+ import { majorOf, MINIMUM_NODE_MAJOR } from "./node-runtime.js";
21
+ export const UPDATE_ORDER = [
22
+ 'install',
23
+ 'verify-install',
24
+ 'refresh-host',
25
+ 'converge-service',
26
+ 'verify-health',
27
+ ];
28
+ /** 이 기계가 그 Node 하한을 만족하는가. 지금 것이 낮으면 다른 후보를 본다. */
29
+ function nodeSatisfies(input, required) {
30
+ const current = majorOf(input.nodeVersion);
31
+ if (current !== null && current >= required)
32
+ return true;
33
+ return (input.nodeCandidates ?? []).some((candidate) => {
34
+ const major = majorOf(candidate.version);
35
+ return major !== null && major >= required;
36
+ });
37
+ }
38
+ /**
39
+ * 무엇을 할 것인가. **아무것도 하지 않는다** — 사실은 호출자가 관측해 넘긴다.
40
+ */
41
+ export function planUpdate(input) {
42
+ if (!input.latest) {
43
+ return {
44
+ state: 'UNKNOWN',
45
+ steps: [],
46
+ ...(input.installed ? { from: input.installed } : {}),
47
+ detail: 'the registry could not be asked — nothing is claimed about being up to date',
48
+ };
49
+ }
50
+ const required = input.requiredNodeMajor ?? MINIMUM_NODE_MAJOR;
51
+ if (!nodeSatisfies(input, required)) {
52
+ return {
53
+ state: 'INCOMPATIBLE',
54
+ ...(input.installed ? { from: input.installed } : {}),
55
+ to: input.latest,
56
+ steps: [],
57
+ detail: `${input.latest} needs Node ${required} or newer, and this machine has none — nothing was installed`,
58
+ };
59
+ }
60
+ // 설치는 됐는데 부를 수 없다. 새 것을 얹으면 그 사실이 덮이므로 먼저 말한다.
61
+ if (input.installed && !input.executableVisible) {
62
+ return {
63
+ state: 'BROKEN',
64
+ from: input.installed,
65
+ to: input.latest,
66
+ steps: [...UPDATE_ORDER],
67
+ rollbackTo: input.installed,
68
+ detail: `${input.installed} is installed but its executable is not visible — updating will reinstall it`,
69
+ };
70
+ }
71
+ if (input.installed === input.latest) {
72
+ return { state: 'CURRENT', from: input.installed, to: input.latest, steps: [] };
73
+ }
74
+ return {
75
+ state: 'UPDATE_AVAILABLE',
76
+ ...(input.installed ? { from: input.installed, rollbackTo: input.installed } : {}),
77
+ to: input.latest,
78
+ steps: [...UPDATE_ORDER],
79
+ };
80
+ }
81
+ /** 사람이 읽는 한 줄. 왜 그 판정인지가 함께 와야 한다. */
82
+ export function updateLine(plan) {
83
+ switch (plan.state) {
84
+ case 'CURRENT':
85
+ return `Up to date — ${plan.to}`;
86
+ case 'UPDATE_AVAILABLE':
87
+ return plan.from ? `Update available — ${plan.from} → ${plan.to}` : `Not installed — ${plan.to} is available`;
88
+ case 'INCOMPATIBLE':
89
+ case 'UNKNOWN':
90
+ case 'BROKEN':
91
+ return `${plan.state}: ${plan.detail ?? '(no detail)'}`;
92
+ }
93
+ }
94
+ /**
95
+ * `engines.node` 가 말하는 하한. `">=24"` · `">=24.0.0 <27"` 같은 형태를 읽는다.
96
+ *
97
+ * **못 읽으면 `undefined` 다** — 0 으로 뭉개면 아무 Node 나 통과하고, 큰 수로 뭉개면
98
+ * 멀쩡한 기계가 INCOMPATIBLE 이 된다. 모르는 것은 모른다고 하고 호출자가 이 build 의
99
+ * 하한을 쓴다.
100
+ */
101
+ export function requiredMajorFrom(engines) {
102
+ if (!engines)
103
+ return undefined;
104
+ const match = /(\d+)/.exec(engines.replace(/^[^\d]*/, ''));
105
+ return match ? Number(match[1]) : undefined;
106
+ }
@@ -85,6 +85,11 @@ export type BootstrapOutcome = {
85
85
  ok: true;
86
86
  runtime: ResolvedRuntime;
87
87
  lock: ProfileLock;
88
+ /**
89
+ * lock 이 이 build 가 아닌 판번호로 적혀 있다. 멈출 일은 아니고 (사람이 고친 설정이
90
+ * 아니다), 기록이 낡았다는 사실은 말한다.
91
+ */
92
+ staleLock?: LockDrift[];
88
93
  }
89
94
  /** attach 전이다. `.asc/` 자체가 없다 — 설정 없이 도는 경로만 허용된다. */
90
95
  | {
@@ -130,3 +135,16 @@ export declare function bootstrapGuard(input: {
130
135
  adapters: Record<string, string>;
131
136
  ascVersion: string;
132
137
  }): Promise<BootstrapOutcome>;
138
+ /**
139
+ * 사람이 정해야 하는 어긋남만 남긴다 (C-14 실측).
140
+ *
141
+ * lock 은 **사람이 바꾼 설정**을 잡으라고 있는 파일이다. runtime 을 갈아 끼우면
142
+ * `ascCore.version` 과 함께 딸려 오는 adapter 판번호가 바뀌고, 그 둘이 재료인
143
+ * `configurationDigest` 도 따라 바뀐다 — 사람이 아무것도 고치지 않았는데 셋이 어긋난다.
144
+ * 그 상태로 멈추면 업데이트가 그 machine 의 모든 프로젝트를 세운다.
145
+ *
146
+ * 실측이 그것이었다: 이 machine 의 workspace 세 개가 전부 `0.3.1 → 0.6.0` 한 가지 이유로
147
+ * 서 있었고, 그 중 둘은 그렇게 여러 릴리스 동안 회차를 돌지 못했다. Profile·Preset·
148
+ * Override·capability 가 어긋나면 지금처럼 멈춘다 — 그것이 사람의 결정이다.
149
+ */
150
+ export declare function configurationDrifts(drifts: readonly LockDrift[]): LockDrift[];
@@ -261,7 +261,28 @@ export async function bootstrapGuard(input) {
261
261
  generatedAt: locked.generatedAt,
262
262
  });
263
263
  const drifts = compareLock(locked, current);
264
- if (drifts.length > 0)
264
+ const configuration = configurationDrifts(drifts);
265
+ if (configuration.length > 0) {
265
266
  return { ok: false, reason: 'LOCK_DRIFT', drifts, runtime: resolved.runtime };
266
- return { ok: true, runtime: resolved.runtime, lock: locked };
267
+ }
268
+ // 남은 것이 있다면 그것은 **이 build 가 옮겨 온 것**이다. 멈출 일이 아니지만 없던 일도
269
+ // 아니므로 그대로 실어 보낸다 — 호출자가 한 줄 말하고, lock 은 다음 재고정 때 따라온다.
270
+ return { ok: true, runtime: resolved.runtime, lock: locked, ...(drifts.length > 0 ? { staleLock: drifts } : {}) };
271
+ }
272
+ /**
273
+ * 사람이 정해야 하는 어긋남만 남긴다 (C-14 실측).
274
+ *
275
+ * lock 은 **사람이 바꾼 설정**을 잡으라고 있는 파일이다. runtime 을 갈아 끼우면
276
+ * `ascCore.version` 과 함께 딸려 오는 adapter 판번호가 바뀌고, 그 둘이 재료인
277
+ * `configurationDigest` 도 따라 바뀐다 — 사람이 아무것도 고치지 않았는데 셋이 어긋난다.
278
+ * 그 상태로 멈추면 업데이트가 그 machine 의 모든 프로젝트를 세운다.
279
+ *
280
+ * 실측이 그것이었다: 이 machine 의 workspace 세 개가 전부 `0.3.1 → 0.6.0` 한 가지 이유로
281
+ * 서 있었고, 그 중 둘은 그렇게 여러 릴리스 동안 회차를 돌지 못했다. Profile·Preset·
282
+ * Override·capability 가 어긋나면 지금처럼 멈춘다 — 그것이 사람의 결정이다.
283
+ */
284
+ export function configurationDrifts(drifts) {
285
+ return drifts.filter((drift) => !VERSION_SHAPED.has(drift.field) && !drift.field.startsWith('adapters.'));
267
286
  }
287
+ /** runtime 을 갈아 끼우는 것만으로 바뀌는 자리들. */
288
+ const VERSION_SHAPED = new Set(['ascCore.version', 'configurationDigest']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asc-agent/runtime",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "ASC (Agent Session Control) \u2014 local-first human-in-the-loop agent control plane: core, CLI, and adapters",
5
5
  "keywords": [
6
6
  "agent",