@asc-agent/runtime 0.3.2 → 0.5.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/session-start.d.ts +20 -0
- package/dist/adapters/claude-code/session-start.js +111 -0
- package/dist/adapters/fixture-surface/index.d.ts +33 -0
- package/dist/adapters/fixture-surface/index.js +97 -0
- package/dist/adapters/gitlab/adapter.js +1 -0
- package/dist/adapters/gitlab/client.d.ts +16 -2
- package/dist/adapters/gitlab/client.js +27 -0
- package/dist/adapters/gitlab/coordination.d.ts +19 -0
- package/dist/adapters/gitlab/coordination.js +111 -0
- package/dist/adapters/gitlab/ports.d.ts +6 -0
- package/dist/adapters/gitlab/ports.js +29 -2
- package/dist/adapters/jam/adapter.d.ts +46 -9
- package/dist/adapters/jam/adapter.js +88 -22
- package/dist/adapters/jam/setup.d.ts +62 -0
- package/dist/adapters/jam/setup.js +85 -0
- package/dist/adapters/local/canonical.d.ts +20 -0
- package/dist/adapters/local/canonical.js +58 -0
- package/dist/adapters/markdown/state-store.js +2 -1
- package/dist/adapters/service/launchd.d.ts +12 -0
- package/dist/adapters/service/launchd.js +97 -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 +98 -0
- package/dist/adapters/webhook/ingress.d.ts +2 -2
- package/dist/cli/asc.js +1080 -145
- package/dist/composition/registry.js +11 -3
- package/dist/composition/runtime.d.ts +36 -0
- package/dist/composition/runtime.js +80 -1
- package/dist/core/attach/setup-plan.d.ts +66 -2
- package/dist/core/attach/setup-plan.js +50 -0
- package/dist/core/binding/types.d.ts +1 -1
- package/dist/core/binding/types.js +1 -0
- package/dist/core/distribution/external-command.d.ts +26 -0
- package/dist/core/distribution/external-command.js +59 -2
- package/dist/core/distribution/persistent-runtime.d.ts +82 -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/runtime/background.d.ts +104 -0
- package/dist/core/runtime/background.js +225 -0
- package/dist/core/runtime/coordination.d.ts +312 -0
- package/dist/core/runtime/coordination.js +247 -0
- package/dist/core/runtime/front.d.ts +58 -0
- package/dist/core/runtime/front.js +50 -1
- package/dist/core/runtime/publish.d.ts +107 -0
- package/dist/core/runtime/publish.js +153 -0
- package/dist/core/runtime/workspaces.d.ts +61 -0
- package/dist/core/runtime/workspaces.js +82 -0
- package/dist/core/workspace/resolve.d.ts +36 -0
- package/dist/core/workspace/resolve.js +110 -3
- package/dist/ports/adapter.d.ts +12 -0
- package/dist/ports/coordination-surface.d.ts +59 -0
- package/dist/ports/coordination-surface.js +16 -0
- package/dist/ports/resource-context.d.ts +5 -0
- package/dist/schemas/profile.d.ts +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { ScopedStore } from '../../ports/state-store.ts';
|
|
2
|
+
import type { TickKind } from './orchestrator.ts';
|
|
3
|
+
/** 이 lease가 사는 열쇠. Monitor의 scan-lease와 다른 층이다 — 저쪽은 한 회차, 이쪽은 루프. */
|
|
4
|
+
export declare const RUNTIME_LEASE_KEY = "runtime-lease";
|
|
5
|
+
/** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
|
|
6
|
+
export declare const LAST_RUN_KEY = "last-run";
|
|
7
|
+
/**
|
|
8
|
+
* lease가 죽은 것으로 보이기까지의 최소 시간.
|
|
9
|
+
*
|
|
10
|
+
* **주기보다 길어야 한다.** 10분마다 도는 루프에 5분 만료를 걸면 회차 사이에 자기 lease가
|
|
11
|
+
* 죽은 것으로 보이고, 두 번째 프로세스가 조용히 끼어든다. 그래서 회수 기준은 주기의
|
|
12
|
+
* 3배이며, 이 값은 그 아래로 내려가지 않게 막는 바닥이다.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MIN_STALE_MS: number;
|
|
15
|
+
/** 주기에서 회수 기준을 정한다. 상수를 Core에 박지 않는다 (C-12 불변식 ③). */
|
|
16
|
+
export declare const staleAfter: (intervalMs: number) => number;
|
|
17
|
+
export type LeaseRecord = {
|
|
18
|
+
/** 누가 잡고 있는가. 사람이 읽고 죽일 수 있어야 한다. */
|
|
19
|
+
owner: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
/** 마지막으로 살아 있다고 말한 시각. 회차마다 갱신된다. */
|
|
22
|
+
at: string;
|
|
23
|
+
/** 언제부터 돌고 있는가. `at`과 달리 갱신되지 않는다. */
|
|
24
|
+
startedAt: string;
|
|
25
|
+
};
|
|
26
|
+
export type LeaseState =
|
|
27
|
+
/** 아무도 안 잡고 있다. */
|
|
28
|
+
{
|
|
29
|
+
kind: 'FREE';
|
|
30
|
+
}
|
|
31
|
+
/** 살아 있는 주인이 있다. */
|
|
32
|
+
| {
|
|
33
|
+
kind: 'HELD';
|
|
34
|
+
record: LeaseRecord;
|
|
35
|
+
}
|
|
36
|
+
/** 기록은 있는데 오래됐다 — 비정상 종료로 남은 것으로 본다. */
|
|
37
|
+
| {
|
|
38
|
+
kind: 'STALE';
|
|
39
|
+
record: LeaseRecord;
|
|
40
|
+
silentFor: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* 루프 하나만 돌게 하는 lease.
|
|
44
|
+
*
|
|
45
|
+
* **이중 기동은 오류가 아니다** (C-12 불변식 ⑥). 늦게 온 쪽은 조용히 물러나고, 이미
|
|
46
|
+
* 도는 쪽이 계속한다. 비정상 종료로 남은 lease는 시간이 지나면 회수된다 — 한 번 죽었다고
|
|
47
|
+
* 영영 못 켜지면 그것이 더 나쁜 고장이다.
|
|
48
|
+
*/
|
|
49
|
+
export declare class RuntimeLease {
|
|
50
|
+
#private;
|
|
51
|
+
constructor(deps: {
|
|
52
|
+
scope: ScopedStore;
|
|
53
|
+
owner: string;
|
|
54
|
+
pid?: number;
|
|
55
|
+
staleMs?: number;
|
|
56
|
+
now?: () => string;
|
|
57
|
+
});
|
|
58
|
+
/** 지금 누가 잡고 있는가. 읽기만 한다 — status가 이것을 그린다. */
|
|
59
|
+
read(): Promise<LeaseState>;
|
|
60
|
+
/**
|
|
61
|
+
* 잡는다. 이미 살아 있는 주인이 있으면 `false` — 조용히 물러나는 것이 계약이다.
|
|
62
|
+
*
|
|
63
|
+
* Monitor scan-lease와 같은 모양이다: `setIfAbsent` 로 원자적으로 걸고, 오래된 것만
|
|
64
|
+
* 지우고 다시 건다. 확인과 쓰기 사이의 틈은 Adapter가 막는다.
|
|
65
|
+
*/
|
|
66
|
+
acquire(): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* 살아 있다고 말한다. 회차마다 부른다 — 이것이 없으면 주기가 만료보다 긴 순간
|
|
69
|
+
* 자기 lease가 죽은 것으로 보인다.
|
|
70
|
+
*
|
|
71
|
+
* **남의 lease를 갱신하지 않는다.** 주인이 바뀌어 있으면 `false` 를 돌려주고, 호출자는
|
|
72
|
+
* 그것을 "내가 밀려났다"로 읽는다.
|
|
73
|
+
*/
|
|
74
|
+
renew(): Promise<boolean>;
|
|
75
|
+
/** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
|
|
76
|
+
release(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
export type BackgroundStatus = {
|
|
79
|
+
lease: LeaseState;
|
|
80
|
+
/** 갈래별 마지막 실행 시각. 없는 갈래는 한 번도 돌지 않았다. */
|
|
81
|
+
lastRun: Partial<Record<TickKind, string>>;
|
|
82
|
+
/** 회수 기준. 사람이 "얼마나 조용하면 죽은 것인가"를 알아야 판단할 수 있다. */
|
|
83
|
+
staleMs: number;
|
|
84
|
+
};
|
|
85
|
+
/** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
|
|
86
|
+
export declare function readBackground(scope: ScopedStore, staleMs: number, now?: () => string): Promise<BackgroundStatus>;
|
|
87
|
+
/**
|
|
88
|
+
* 사람이 읽는 줄.
|
|
89
|
+
*
|
|
90
|
+
* **"안 돌고 있다"와 "돌았는데 변화가 없다"를 합치지 않는다** (C-12 불변식 ⑫과 같은 태도).
|
|
91
|
+
* 그래서 lease 상태와 마지막 회차 시각을 각각 말한다 — 둘 중 하나만 보면 오해한다.
|
|
92
|
+
*/
|
|
93
|
+
export declare function renderBackground(status: BackgroundStatus): string[];
|
|
94
|
+
/**
|
|
95
|
+
* 파일 하나짜리 ScopedStore (설계 §5.1).
|
|
96
|
+
*
|
|
97
|
+
* **서비스 lease 와 workspace lease 는 다른 소유 영역이다.** 저쪽은 "이 workspace 의
|
|
98
|
+
* 이번 회차를 누가 잡았는가"이고, 이쪽은 "이 기계의 runtime 을 지금 누가 도는가"다.
|
|
99
|
+
* 둘을 합치면 workspace 가 늘 때마다 기계 수준 직렬화가 무너진다.
|
|
100
|
+
*
|
|
101
|
+
* workspace 상태 저장소를 기계 뿌리에 열지 않는 이유: 그러면 ~/.asc 에 workspace 용
|
|
102
|
+
* 파일들이 생기고, 그것은 그 자리에 없어야 할 것들이다.
|
|
103
|
+
*/
|
|
104
|
+
export declare function fileScope(path: string): ScopedStore;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Background Runtime — 대화가 닫혀 있어도 도는 자리 (C-12 §0·§1.1).
|
|
2
|
+
//
|
|
3
|
+
// Orchestrator는 "언제 무엇을 부를지"를 이미 안다. 없던 것은 그 회차를 **누가 계속
|
|
4
|
+
// 돌리는가**와, 돌고 있는지를 **사람이 어떻게 보는가**였다:
|
|
5
|
+
//
|
|
6
|
+
// asc runtime start 터미널을 잡고 있어야 했다 — 창을 닫으면 감시가 죽는다
|
|
7
|
+
// asc runtime tick 한 회차만 돈다. 부를 사람이 없으면 아무 일도 안 일어난다
|
|
8
|
+
// asc runtime status 어느 빌드를 쓰는지만 답했다 (C-14 §4) — 감시가 사는지는 몰랐다
|
|
9
|
+
//
|
|
10
|
+
// 여기는 그 셋을 잇는 **상태**만 다룬다. 판정도, 감시도, 승인도 하지 않는다
|
|
11
|
+
// (C-12 불변식 ②). scheduler 제품도 모른다 (불변식 ④) — cron이 tick을 부르든, 떨어져
|
|
12
|
+
// 나간 프로세스가 스스로 돌든 같은 lease를 지난다.
|
|
13
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
/** 이 lease가 사는 열쇠. Monitor의 scan-lease와 다른 층이다 — 저쪽은 한 회차, 이쪽은 루프. */
|
|
16
|
+
export const RUNTIME_LEASE_KEY = 'runtime-lease';
|
|
17
|
+
/** 마지막 회차 시각이 사는 열쇠. Orchestrator가 쓰고 status가 읽는다. */
|
|
18
|
+
export const LAST_RUN_KEY = 'last-run';
|
|
19
|
+
/**
|
|
20
|
+
* lease가 죽은 것으로 보이기까지의 최소 시간.
|
|
21
|
+
*
|
|
22
|
+
* **주기보다 길어야 한다.** 10분마다 도는 루프에 5분 만료를 걸면 회차 사이에 자기 lease가
|
|
23
|
+
* 죽은 것으로 보이고, 두 번째 프로세스가 조용히 끼어든다. 그래서 회수 기준은 주기의
|
|
24
|
+
* 3배이며, 이 값은 그 아래로 내려가지 않게 막는 바닥이다.
|
|
25
|
+
*/
|
|
26
|
+
export const MIN_STALE_MS = 5 * 60_000;
|
|
27
|
+
/** 주기에서 회수 기준을 정한다. 상수를 Core에 박지 않는다 (C-12 불변식 ③). */
|
|
28
|
+
export const staleAfter = (intervalMs) => Math.max(MIN_STALE_MS, intervalMs * 3);
|
|
29
|
+
/**
|
|
30
|
+
* 루프 하나만 돌게 하는 lease.
|
|
31
|
+
*
|
|
32
|
+
* **이중 기동은 오류가 아니다** (C-12 불변식 ⑥). 늦게 온 쪽은 조용히 물러나고, 이미
|
|
33
|
+
* 도는 쪽이 계속한다. 비정상 종료로 남은 lease는 시간이 지나면 회수된다 — 한 번 죽었다고
|
|
34
|
+
* 영영 못 켜지면 그것이 더 나쁜 고장이다.
|
|
35
|
+
*/
|
|
36
|
+
export class RuntimeLease {
|
|
37
|
+
#scope;
|
|
38
|
+
#owner;
|
|
39
|
+
#pid;
|
|
40
|
+
#staleMs;
|
|
41
|
+
#now;
|
|
42
|
+
#startedAt;
|
|
43
|
+
constructor(deps) {
|
|
44
|
+
this.#scope = deps.scope;
|
|
45
|
+
this.#owner = deps.owner;
|
|
46
|
+
this.#pid = deps.pid ?? process.pid;
|
|
47
|
+
this.#staleMs = deps.staleMs ?? MIN_STALE_MS;
|
|
48
|
+
this.#now = deps.now ?? (() => new Date().toISOString());
|
|
49
|
+
}
|
|
50
|
+
/** 지금 누가 잡고 있는가. 읽기만 한다 — status가 이것을 그린다. */
|
|
51
|
+
async read() {
|
|
52
|
+
const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
|
|
53
|
+
if (!raw)
|
|
54
|
+
return { kind: 'FREE' };
|
|
55
|
+
let record;
|
|
56
|
+
try {
|
|
57
|
+
record = JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// 읽을 수 없는 lease는 죽은 것으로 본다. 다만 무엇이 있었는지는 남긴다.
|
|
61
|
+
return { kind: 'STALE', record: { owner: '(unreadable)', pid: 0, at: '', startedAt: '' }, silentFor: Infinity };
|
|
62
|
+
}
|
|
63
|
+
const silentFor = new Date(this.#now()).getTime() - new Date(record.at).getTime();
|
|
64
|
+
// 시계가 뒤로 갔거나 기록이 깨졌으면 살아 있는 쪽으로 읽는다 — 남의 lease를 뺏는
|
|
65
|
+
// 것보다 한 회차 쉬는 편이 싸다.
|
|
66
|
+
if (Number.isNaN(silentFor))
|
|
67
|
+
return { kind: 'HELD', record };
|
|
68
|
+
return silentFor >= this.#staleMs ? { kind: 'STALE', record, silentFor } : { kind: 'HELD', record };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 잡는다. 이미 살아 있는 주인이 있으면 `false` — 조용히 물러나는 것이 계약이다.
|
|
72
|
+
*
|
|
73
|
+
* Monitor scan-lease와 같은 모양이다: `setIfAbsent` 로 원자적으로 걸고, 오래된 것만
|
|
74
|
+
* 지우고 다시 건다. 확인과 쓰기 사이의 틈은 Adapter가 막는다.
|
|
75
|
+
*/
|
|
76
|
+
async acquire() {
|
|
77
|
+
const at = this.#now();
|
|
78
|
+
this.#startedAt = at;
|
|
79
|
+
const mine = JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: at });
|
|
80
|
+
if (await this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine))
|
|
81
|
+
return true;
|
|
82
|
+
const state = await this.read();
|
|
83
|
+
if (state.kind === 'HELD')
|
|
84
|
+
return false;
|
|
85
|
+
await this.#scope.delete(RUNTIME_LEASE_KEY);
|
|
86
|
+
return this.#scope.setIfAbsent(RUNTIME_LEASE_KEY, mine);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 살아 있다고 말한다. 회차마다 부른다 — 이것이 없으면 주기가 만료보다 긴 순간
|
|
90
|
+
* 자기 lease가 죽은 것으로 보인다.
|
|
91
|
+
*
|
|
92
|
+
* **남의 lease를 갱신하지 않는다.** 주인이 바뀌어 있으면 `false` 를 돌려주고, 호출자는
|
|
93
|
+
* 그것을 "내가 밀려났다"로 읽는다.
|
|
94
|
+
*/
|
|
95
|
+
async renew() {
|
|
96
|
+
const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
|
|
97
|
+
if (raw) {
|
|
98
|
+
try {
|
|
99
|
+
const held = JSON.parse(raw);
|
|
100
|
+
if (held.owner !== this.#owner)
|
|
101
|
+
return false;
|
|
102
|
+
this.#startedAt ??= held.startedAt;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// 못 읽는 lease 위에 내 것을 덮어쓴다 — 아래에서 다시 쓴다
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const at = this.#now();
|
|
109
|
+
this.#startedAt ??= at;
|
|
110
|
+
await this.#scope.set(RUNTIME_LEASE_KEY, JSON.stringify({ owner: this.#owner, pid: this.#pid, at, startedAt: this.#startedAt }));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** 내 것일 때만 놓는다. 남이 이미 잡았으면 건드리지 않는다. */
|
|
114
|
+
async release() {
|
|
115
|
+
const raw = await this.#scope.get(RUNTIME_LEASE_KEY);
|
|
116
|
+
if (!raw)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
if (JSON.parse(raw).owner !== this.#owner)
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// 못 읽는 것은 내가 남긴 것으로 보고 치운다
|
|
124
|
+
}
|
|
125
|
+
await this.#scope.delete(RUNTIME_LEASE_KEY);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** 상태를 모은다. **읽기만 한다** — 보는 것이 상태를 바꾸면 아무도 못 본다. */
|
|
129
|
+
export async function readBackground(scope, staleMs, now = () => new Date().toISOString()) {
|
|
130
|
+
const lease = await new RuntimeLease({ scope, owner: '(reader)', staleMs, now }).read();
|
|
131
|
+
const raw = await scope.get(LAST_RUN_KEY);
|
|
132
|
+
let lastRun = {};
|
|
133
|
+
if (raw) {
|
|
134
|
+
try {
|
|
135
|
+
lastRun = JSON.parse(raw);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// 깨진 기록은 없는 것으로 본다 — 아래 렌더가 "한 번도 돌지 않았다"로 말한다
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { lease, lastRun, staleMs };
|
|
142
|
+
}
|
|
143
|
+
const ORDER = ['delta', 'reconcile', 'census', 'digest'];
|
|
144
|
+
/**
|
|
145
|
+
* 사람이 읽는 줄.
|
|
146
|
+
*
|
|
147
|
+
* **"안 돌고 있다"와 "돌았는데 변화가 없다"를 합치지 않는다** (C-12 불변식 ⑫과 같은 태도).
|
|
148
|
+
* 그래서 lease 상태와 마지막 회차 시각을 각각 말한다 — 둘 중 하나만 보면 오해한다.
|
|
149
|
+
*/
|
|
150
|
+
export function renderBackground(status) {
|
|
151
|
+
const lines = [];
|
|
152
|
+
switch (status.lease.kind) {
|
|
153
|
+
case 'FREE':
|
|
154
|
+
lines.push('Background runtime: not running — `asc runtime start --detach` keeps it observing');
|
|
155
|
+
break;
|
|
156
|
+
case 'HELD':
|
|
157
|
+
lines.push(`Background runtime: running (pid ${status.lease.record.pid}, since ${status.lease.record.startedAt}, last heartbeat ${status.lease.record.at})`);
|
|
158
|
+
break;
|
|
159
|
+
case 'STALE':
|
|
160
|
+
lines.push(`Background runtime: not running — a lease from pid ${status.lease.record.pid} has been silent for ` +
|
|
161
|
+
`${Math.round(status.lease.silentFor / 60_000)} min and will be reclaimed on the next start`);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
const ran = ORDER.filter((kind) => status.lastRun[kind]);
|
|
165
|
+
if (ran.length === 0) {
|
|
166
|
+
// 설정만 하고 켜지 않은 상태다. 조용한 것과 구분해서 말한다.
|
|
167
|
+
lines.push(' no pass has run yet');
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
for (const kind of ORDER) {
|
|
171
|
+
lines.push(` ${kind}: ${status.lastRun[kind] ?? 'never run'}`);
|
|
172
|
+
}
|
|
173
|
+
return lines;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* 파일 하나짜리 ScopedStore (설계 §5.1).
|
|
177
|
+
*
|
|
178
|
+
* **서비스 lease 와 workspace lease 는 다른 소유 영역이다.** 저쪽은 "이 workspace 의
|
|
179
|
+
* 이번 회차를 누가 잡았는가"이고, 이쪽은 "이 기계의 runtime 을 지금 누가 도는가"다.
|
|
180
|
+
* 둘을 합치면 workspace 가 늘 때마다 기계 수준 직렬화가 무너진다.
|
|
181
|
+
*
|
|
182
|
+
* workspace 상태 저장소를 기계 뿌리에 열지 않는 이유: 그러면 ~/.asc 에 workspace 용
|
|
183
|
+
* 파일들이 생기고, 그것은 그 자리에 없어야 할 것들이다.
|
|
184
|
+
*/
|
|
185
|
+
export function fileScope(path) {
|
|
186
|
+
const read = async () => {
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// 없거나 깨진 파일은 빈 것으로 본다 — lease 는 시간으로 회수되므로 안전하다
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
// tmp+rename. 반쯤 쓰인 lease 를 다른 프로세스가 읽으면 판정이 흔들린다 (C-11 불변식 ⑨).
|
|
196
|
+
const write = async (data) => {
|
|
197
|
+
await mkdir(dirname(path), { recursive: true });
|
|
198
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
199
|
+
await writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
200
|
+
await rename(tmp, path);
|
|
201
|
+
};
|
|
202
|
+
return {
|
|
203
|
+
async get(key) {
|
|
204
|
+
return (await read())[key] ?? null;
|
|
205
|
+
},
|
|
206
|
+
async set(key, value) {
|
|
207
|
+
await write({ ...(await read()), [key]: value });
|
|
208
|
+
},
|
|
209
|
+
async delete(key) {
|
|
210
|
+
const data = await read();
|
|
211
|
+
delete data[key];
|
|
212
|
+
await write(data);
|
|
213
|
+
},
|
|
214
|
+
async keys(prefix) {
|
|
215
|
+
return Object.keys(await read()).filter((key) => (prefix ? key.startsWith(prefix) : true));
|
|
216
|
+
},
|
|
217
|
+
async setIfAbsent(key, value) {
|
|
218
|
+
const data = await read();
|
|
219
|
+
if (key in data)
|
|
220
|
+
return false;
|
|
221
|
+
await write({ ...data, [key]: value });
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ScopedStore } from '../../ports/state-store.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 외부 시스템이 준 **안정적인** 신원 (F3).
|
|
5
|
+
*
|
|
6
|
+
* 사람이 보는 링크는 여기 있어도 되지만 그것이 정본은 아니다. 링크의 모양이 예상과
|
|
7
|
+
* 다르다는 이유로 생성이 실패했다고 읽고 하나 더 만든 사고가 실제로 있었다 — 그때
|
|
8
|
+
* 대조할 수 있는 다른 값이 계약에 없었다.
|
|
9
|
+
*/
|
|
10
|
+
export declare const RemoteIdentity: z.ZodObject<{
|
|
11
|
+
/** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
|
|
12
|
+
adapter: z.ZodString;
|
|
13
|
+
/** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
|
|
14
|
+
objectType: z.ZodString;
|
|
15
|
+
/** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
|
|
16
|
+
objectId: z.ZodString;
|
|
17
|
+
/** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
|
|
18
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
19
|
+
/**
|
|
20
|
+
* 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
|
|
21
|
+
*/
|
|
22
|
+
locator: z.ZodOptional<z.ZodString>;
|
|
23
|
+
/** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
|
|
24
|
+
revisionMarker: z.ZodOptional<z.ZodString>;
|
|
25
|
+
}, "strip", z.ZodTypeAny, {
|
|
26
|
+
adapter: string;
|
|
27
|
+
objectType: string;
|
|
28
|
+
objectId: string;
|
|
29
|
+
resource?: string | undefined;
|
|
30
|
+
revisionMarker?: string | undefined;
|
|
31
|
+
locator?: string | undefined;
|
|
32
|
+
}, {
|
|
33
|
+
adapter: string;
|
|
34
|
+
objectType: string;
|
|
35
|
+
objectId: string;
|
|
36
|
+
resource?: string | undefined;
|
|
37
|
+
revisionMarker?: string | undefined;
|
|
38
|
+
locator?: string | undefined;
|
|
39
|
+
}>;
|
|
40
|
+
export type RemoteIdentity = z.infer<typeof RemoteIdentity>;
|
|
41
|
+
/**
|
|
42
|
+
* 기대가 실제로 밖에 게시됐다는 증거.
|
|
43
|
+
*
|
|
44
|
+
* **이 레코드를 만드는 것이 게시가 아니다.** 게시는 adapter 가 하고, 여기 남는 것은
|
|
45
|
+
* 그것이 일어났다는 관측이다 — 그 구분이 사라지면 "적어 뒀으니 됐다"가 다시 성립한다.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CommunicationEvidence: z.ZodObject<{
|
|
48
|
+
evidenceId: z.ZodString;
|
|
49
|
+
/** 어느 기대에 대한 것인가. 지금은 Bounded Query 가 기대의 정본이다 (C-04). */
|
|
50
|
+
queryId: z.ZodString;
|
|
51
|
+
/** 어느 binding 을 통해 나갔는가 (C-09). 역할 이름이며 provider 이름이 아니다. */
|
|
52
|
+
bindingRole: z.ZodOptional<z.ZodString>;
|
|
53
|
+
identity: z.ZodObject<{
|
|
54
|
+
/** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
|
|
55
|
+
adapter: z.ZodString;
|
|
56
|
+
/** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
|
|
57
|
+
objectType: z.ZodString;
|
|
58
|
+
/** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
|
|
59
|
+
objectId: z.ZodString;
|
|
60
|
+
/** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
|
|
61
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
62
|
+
/**
|
|
63
|
+
* 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
|
|
64
|
+
*/
|
|
65
|
+
locator: z.ZodOptional<z.ZodString>;
|
|
66
|
+
/** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
|
|
67
|
+
revisionMarker: z.ZodOptional<z.ZodString>;
|
|
68
|
+
}, "strip", z.ZodTypeAny, {
|
|
69
|
+
adapter: string;
|
|
70
|
+
objectType: string;
|
|
71
|
+
objectId: string;
|
|
72
|
+
resource?: string | undefined;
|
|
73
|
+
revisionMarker?: string | undefined;
|
|
74
|
+
locator?: string | undefined;
|
|
75
|
+
}, {
|
|
76
|
+
adapter: string;
|
|
77
|
+
objectType: string;
|
|
78
|
+
objectId: string;
|
|
79
|
+
resource?: string | undefined;
|
|
80
|
+
revisionMarker?: string | undefined;
|
|
81
|
+
locator?: string | undefined;
|
|
82
|
+
}>;
|
|
83
|
+
/** 누구에게 닿아야 하는가. 프로젝트 정책이 정하고 여기서는 그대로 보관한다. */
|
|
84
|
+
audience: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
85
|
+
publishedAt: z.ZodString;
|
|
86
|
+
/** 이 사실을 우리가 언제 봤는가. 게시 시각과 다를 수 있다. */
|
|
87
|
+
observedAt: z.ZodString;
|
|
88
|
+
/** 선언인가 관측인가 (C-10 의 같은 필드와 같은 뜻). */
|
|
89
|
+
evidenceSource: z.ZodString;
|
|
90
|
+
}, "strip", z.ZodTypeAny, {
|
|
91
|
+
observedAt: string;
|
|
92
|
+
identity: {
|
|
93
|
+
adapter: string;
|
|
94
|
+
objectType: string;
|
|
95
|
+
objectId: string;
|
|
96
|
+
resource?: string | undefined;
|
|
97
|
+
revisionMarker?: string | undefined;
|
|
98
|
+
locator?: string | undefined;
|
|
99
|
+
};
|
|
100
|
+
evidenceSource: string;
|
|
101
|
+
evidenceId: string;
|
|
102
|
+
queryId: string;
|
|
103
|
+
audience: string[];
|
|
104
|
+
publishedAt: string;
|
|
105
|
+
bindingRole?: string | undefined;
|
|
106
|
+
}, {
|
|
107
|
+
observedAt: string;
|
|
108
|
+
identity: {
|
|
109
|
+
adapter: string;
|
|
110
|
+
objectType: string;
|
|
111
|
+
objectId: string;
|
|
112
|
+
resource?: string | undefined;
|
|
113
|
+
revisionMarker?: string | undefined;
|
|
114
|
+
locator?: string | undefined;
|
|
115
|
+
};
|
|
116
|
+
evidenceSource: string;
|
|
117
|
+
evidenceId: string;
|
|
118
|
+
queryId: string;
|
|
119
|
+
publishedAt: string;
|
|
120
|
+
bindingRole?: string | undefined;
|
|
121
|
+
audience?: string[] | undefined;
|
|
122
|
+
}>;
|
|
123
|
+
export type CommunicationEvidence = z.infer<typeof CommunicationEvidence>;
|
|
124
|
+
/**
|
|
125
|
+
* 그 게시물에 외부 응답이 도착했다는 증거.
|
|
126
|
+
*
|
|
127
|
+
* **답의 의미를 판정하지 않는다.** 이것은 `QueryAnswer` 가 아니고, 자동으로 그것이
|
|
128
|
+
* 되지도 않는다 — 누가 무엇을 결정할 수 있는지는 C-04 의 권한 판정이고, 사람 승인은
|
|
129
|
+
* C-13 이다. 여기 있는 것은 "왔다"까지다.
|
|
130
|
+
*/
|
|
131
|
+
export declare const ResponseEvidence: z.ZodObject<{
|
|
132
|
+
evidenceId: z.ZodString;
|
|
133
|
+
/** 어느 게시물에 대한 응답인가. */
|
|
134
|
+
communicationId: z.ZodString;
|
|
135
|
+
identity: z.ZodObject<{
|
|
136
|
+
/** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
|
|
137
|
+
adapter: z.ZodString;
|
|
138
|
+
/** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
|
|
139
|
+
objectType: z.ZodString;
|
|
140
|
+
/** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
|
|
141
|
+
objectId: z.ZodString;
|
|
142
|
+
/** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
|
|
143
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
144
|
+
/**
|
|
145
|
+
* 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
|
|
146
|
+
*/
|
|
147
|
+
locator: z.ZodOptional<z.ZodString>;
|
|
148
|
+
/** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
|
|
149
|
+
revisionMarker: z.ZodOptional<z.ZodString>;
|
|
150
|
+
}, "strip", z.ZodTypeAny, {
|
|
151
|
+
adapter: string;
|
|
152
|
+
objectType: string;
|
|
153
|
+
objectId: string;
|
|
154
|
+
resource?: string | undefined;
|
|
155
|
+
revisionMarker?: string | undefined;
|
|
156
|
+
locator?: string | undefined;
|
|
157
|
+
}, {
|
|
158
|
+
adapter: string;
|
|
159
|
+
objectType: string;
|
|
160
|
+
objectId: string;
|
|
161
|
+
resource?: string | undefined;
|
|
162
|
+
revisionMarker?: string | undefined;
|
|
163
|
+
locator?: string | undefined;
|
|
164
|
+
}>;
|
|
165
|
+
/** 그 시스템이 말하는 응답자. 우리 역할 이름으로 번역하지 않는다. */
|
|
166
|
+
responder: z.ZodOptional<z.ZodString>;
|
|
167
|
+
receivedAt: z.ZodString;
|
|
168
|
+
observedAt: z.ZodString;
|
|
169
|
+
evidenceSource: z.ZodString;
|
|
170
|
+
}, "strip", z.ZodTypeAny, {
|
|
171
|
+
observedAt: string;
|
|
172
|
+
identity: {
|
|
173
|
+
adapter: string;
|
|
174
|
+
objectType: string;
|
|
175
|
+
objectId: string;
|
|
176
|
+
resource?: string | undefined;
|
|
177
|
+
revisionMarker?: string | undefined;
|
|
178
|
+
locator?: string | undefined;
|
|
179
|
+
};
|
|
180
|
+
evidenceSource: string;
|
|
181
|
+
evidenceId: string;
|
|
182
|
+
communicationId: string;
|
|
183
|
+
receivedAt: string;
|
|
184
|
+
responder?: string | undefined;
|
|
185
|
+
}, {
|
|
186
|
+
observedAt: string;
|
|
187
|
+
identity: {
|
|
188
|
+
adapter: string;
|
|
189
|
+
objectType: string;
|
|
190
|
+
objectId: string;
|
|
191
|
+
resource?: string | undefined;
|
|
192
|
+
revisionMarker?: string | undefined;
|
|
193
|
+
locator?: string | undefined;
|
|
194
|
+
};
|
|
195
|
+
evidenceSource: string;
|
|
196
|
+
evidenceId: string;
|
|
197
|
+
communicationId: string;
|
|
198
|
+
receivedAt: string;
|
|
199
|
+
responder?: string | undefined;
|
|
200
|
+
}>;
|
|
201
|
+
export type ResponseEvidence = z.infer<typeof ResponseEvidence>;
|
|
202
|
+
/**
|
|
203
|
+
* 파생 상태. **저장하지 않는다** — 위 증거들에서 매번 계산한다.
|
|
204
|
+
*/
|
|
205
|
+
export type CoordinationState =
|
|
206
|
+
/** 기대는 있는데 밖에 나간 적이 없다. */
|
|
207
|
+
'UNPUBLISHED'
|
|
208
|
+
/** 나갔고, 답을 기다린다. */
|
|
209
|
+
| 'WAITING_EXTERNAL'
|
|
210
|
+
/** 답이 왔다. 그 답이 무엇을 뜻하는지는 여기서 정하지 않는다. */
|
|
211
|
+
| 'RESPONSE_RECEIVED'
|
|
212
|
+
/** 나갔고, 애초에 답을 기대하지 않는다. */
|
|
213
|
+
| 'PUBLISHED';
|
|
214
|
+
export type CoordinationView = {
|
|
215
|
+
queryId: string;
|
|
216
|
+
state: CoordinationState;
|
|
217
|
+
/** 이 기대가 답을 기다리는가. 기대 쪽 사실이며 증거가 아니다. */
|
|
218
|
+
expectsResponse: boolean;
|
|
219
|
+
communications: CommunicationEvidence[];
|
|
220
|
+
responses: ResponseEvidence[];
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* 기대 하나의 지금 상태를 계산한다.
|
|
224
|
+
*
|
|
225
|
+
* 순서가 곧 규칙이다: 응답이 있으면 왔다, 없으면 기다리는지 아닌지, 게시가 없으면
|
|
226
|
+
* 아직 나가지 않았다. **없는 것을 있는 것으로 올리는 방향은 없다.**
|
|
227
|
+
*/
|
|
228
|
+
export declare function deriveCoordination(input: {
|
|
229
|
+
queryId: string;
|
|
230
|
+
expectsResponse: boolean;
|
|
231
|
+
communications: readonly CommunicationEvidence[];
|
|
232
|
+
responses: readonly ResponseEvidence[];
|
|
233
|
+
}): CoordinationView;
|
|
234
|
+
export type AppendOutcome<T> = {
|
|
235
|
+
ok: true;
|
|
236
|
+
evidence: T;
|
|
237
|
+
}
|
|
238
|
+
/** 같은 id 가 이미 있다. 덮지 않는다 — 증거는 한 번만 쓰인다 (C-10 과 같은 이유). */
|
|
239
|
+
| {
|
|
240
|
+
ok: false;
|
|
241
|
+
reason: 'ALREADY_EXISTS';
|
|
242
|
+
detail: string;
|
|
243
|
+
}
|
|
244
|
+
/** 가리키는 게시물이 없다. 없는 것에 응답을 붙이지 않는다. */
|
|
245
|
+
| {
|
|
246
|
+
ok: false;
|
|
247
|
+
reason: 'COMMUNICATION_NOT_FOUND';
|
|
248
|
+
detail: string;
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* 조율 증거 원장. append-only 이며 `setIfAbsent` 위에 선다.
|
|
252
|
+
*
|
|
253
|
+
* 잃는 것이 표시값이 아니라 **누가 무엇을 밖에 내보냈고 누가 답했는가**이기 때문에,
|
|
254
|
+
* 나중 쓰기가 앞선 것을 덮지 않는다.
|
|
255
|
+
*/
|
|
256
|
+
/** 밖에서 본 글 하나. adapter 어휘를 그대로 받되 Core 는 세 가지만 본다. */
|
|
257
|
+
export type ObservedRemark = {
|
|
258
|
+
id: string;
|
|
259
|
+
author: string;
|
|
260
|
+
at: string;
|
|
261
|
+
/** 사람이 쓴 것이 아니라 그 시스템이 남긴 자국인가. */
|
|
262
|
+
system?: boolean;
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* 게시물에 붙은 글 중 **응답으로 셀 것**을 고른다.
|
|
266
|
+
*
|
|
267
|
+
* 두 가지를 뺀다: 그 시스템이 스스로 남긴 자국(커밋 언급·배정 변경)과, 우리가 쓴 글.
|
|
268
|
+
* 둘 다 세면 아무도 답하지 않은 스레드가 답이 온 것으로 보인다 — 이 구조가 막으려는
|
|
269
|
+
* 바로 그 착각이다.
|
|
270
|
+
*
|
|
271
|
+
* **답의 의미는 판정하지 않는다.** 여기서 나오는 것은 "왔다"까지이고, 그것이 결정인지
|
|
272
|
+
* 승인인지는 C-04·C-13 의 몫이다.
|
|
273
|
+
*/
|
|
274
|
+
export declare function responsesFrom(communication: CommunicationEvidence, remarks: readonly ObservedRemark[], mine: ReadonlySet<string>): Omit<ResponseEvidence, 'observedAt'>[];
|
|
275
|
+
export declare class CoordinationLedger {
|
|
276
|
+
#private;
|
|
277
|
+
constructor(scope: ScopedStore, now?: () => string);
|
|
278
|
+
publishRecorded(input: Omit<CommunicationEvidence, 'observedAt'> & {
|
|
279
|
+
observedAt?: string;
|
|
280
|
+
}): Promise<AppendOutcome<CommunicationEvidence>>;
|
|
281
|
+
responseRecorded(input: Omit<ResponseEvidence, 'observedAt'> & {
|
|
282
|
+
observedAt?: string;
|
|
283
|
+
}): Promise<AppendOutcome<ResponseEvidence>>;
|
|
284
|
+
communications(): Promise<CommunicationEvidence[]>;
|
|
285
|
+
responses(): Promise<ResponseEvidence[]>;
|
|
286
|
+
/**
|
|
287
|
+
* 이미 이 게시물이 있는가 — **원격 신원으로** 찾는다 (F3).
|
|
288
|
+
*
|
|
289
|
+
* 같은 것을 두 번 만들지 않기 위한 조회다. 제목이나 링크가 아니라 adapter 가 준
|
|
290
|
+
* 안정 id 로 본다.
|
|
291
|
+
*/
|
|
292
|
+
findByIdentity(identity: Pick<RemoteIdentity, 'adapter' | 'objectType' | 'objectId'>): Promise<CommunicationEvidence | null>;
|
|
293
|
+
/** 기대 하나에 달린 증거 전부. */
|
|
294
|
+
forQuery(queryId: string): Promise<{
|
|
295
|
+
communications: CommunicationEvidence[];
|
|
296
|
+
responses: ResponseEvidence[];
|
|
297
|
+
}>;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* 기대 목록과 증거를 합쳐 지금 상태를 낸다.
|
|
301
|
+
*
|
|
302
|
+
* 기대는 이 모듈이 만들지 않는다 — Bounded Query 가 정본이고, 여기서는 그것을 받아
|
|
303
|
+
* 증거와 맞춘다. 기대가 없으면 조율도 없다.
|
|
304
|
+
*/
|
|
305
|
+
export declare function viewCoordination(ledger: CoordinationLedger, expectations: readonly {
|
|
306
|
+
id: string;
|
|
307
|
+
expectsResponse: boolean;
|
|
308
|
+
}[]): Promise<CoordinationView[]>;
|
|
309
|
+
/**
|
|
310
|
+
* 사람이 읽는 줄. **없는 것을 0 으로 그리지 않는다** — 기대가 없으면 그렇게 말한다.
|
|
311
|
+
*/
|
|
312
|
+
export declare function coordinationLines(views: readonly CoordinationView[]): string[];
|