@asc-agent/runtime 0.1.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/LICENSE +15 -0
- package/README.md +22 -0
- package/dist/adapters/claude-code/binding.d.ts +5 -0
- package/dist/adapters/claude-code/binding.js +14 -0
- package/dist/adapters/claude-code/guard.d.ts +54 -0
- package/dist/adapters/claude-code/guard.js +295 -0
- package/dist/adapters/claude-code/install.d.ts +67 -0
- package/dist/adapters/claude-code/install.js +231 -0
- package/dist/adapters/claude-code/observer.d.ts +38 -0
- package/dist/adapters/claude-code/observer.js +77 -0
- package/dist/adapters/claude-code/probe.d.ts +53 -0
- package/dist/adapters/claude-code/probe.js +129 -0
- package/dist/adapters/claude-code/skill.d.ts +8 -0
- package/dist/adapters/claude-code/skill.js +267 -0
- package/dist/adapters/fixture-work/index.d.ts +34 -0
- package/dist/adapters/fixture-work/index.js +78 -0
- package/dist/adapters/github/adapter.d.ts +32 -0
- package/dist/adapters/github/adapter.js +95 -0
- package/dist/adapters/github/client.d.ts +30 -0
- package/dist/adapters/github/client.js +90 -0
- package/dist/adapters/github/context.d.ts +44 -0
- package/dist/adapters/github/context.js +170 -0
- package/dist/adapters/github/event-source.d.ts +55 -0
- package/dist/adapters/github/event-source.js +183 -0
- package/dist/adapters/github/scm.d.ts +50 -0
- package/dist/adapters/github/scm.js +104 -0
- package/dist/adapters/gitlab/adapter.d.ts +36 -0
- package/dist/adapters/gitlab/adapter.js +117 -0
- package/dist/adapters/gitlab/client.d.ts +30 -0
- package/dist/adapters/gitlab/client.js +47 -0
- package/dist/adapters/gitlab/ports.d.ts +40 -0
- package/dist/adapters/gitlab/ports.js +186 -0
- package/dist/adapters/jam/adapter.d.ts +53 -0
- package/dist/adapters/jam/adapter.js +144 -0
- package/dist/adapters/jam/event-source.d.ts +28 -0
- package/dist/adapters/jam/event-source.js +65 -0
- package/dist/adapters/jam/mcp-client.d.ts +50 -0
- package/dist/adapters/jam/mcp-client.js +218 -0
- package/dist/adapters/jam/ports.d.ts +51 -0
- package/dist/adapters/jam/ports.js +167 -0
- package/dist/adapters/local/identity.d.ts +27 -0
- package/dist/adapters/local/identity.js +40 -0
- package/dist/adapters/local/presentation.d.ts +14 -0
- package/dist/adapters/local/presentation.js +42 -0
- package/dist/adapters/markdown/layout.d.ts +15 -0
- package/dist/adapters/markdown/layout.js +49 -0
- package/dist/adapters/markdown/serialize.d.ts +9 -0
- package/dist/adapters/markdown/serialize.js +120 -0
- package/dist/adapters/markdown/state-store.d.ts +23 -0
- package/dist/adapters/markdown/state-store.js +316 -0
- package/dist/adapters/mattermost/client.d.ts +23 -0
- package/dist/adapters/mattermost/client.js +54 -0
- package/dist/adapters/mattermost/presentation.d.ts +43 -0
- package/dist/adapters/mattermost/presentation.js +88 -0
- package/dist/adapters/memory/mocks.d.ts +87 -0
- package/dist/adapters/memory/mocks.js +163 -0
- package/dist/adapters/memory/runtime-binding.d.ts +23 -0
- package/dist/adapters/memory/runtime-binding.js +118 -0
- package/dist/adapters/memory/state-store.d.ts +17 -0
- package/dist/adapters/memory/state-store.js +125 -0
- package/dist/adapters/text/renderer.d.ts +7 -0
- package/dist/adapters/text/renderer.js +81 -0
- package/dist/adapters/webhook/ingress.d.ts +103 -0
- package/dist/adapters/webhook/ingress.js +150 -0
- package/dist/cli/asc.d.ts +16 -0
- package/dist/cli/asc.js +2914 -0
- package/dist/cli/identity-config.d.ts +7 -0
- package/dist/cli/identity-config.js +31 -0
- package/dist/composition/observe.d.ts +21 -0
- package/dist/composition/observe.js +72 -0
- package/dist/composition/registry.d.ts +33 -0
- package/dist/composition/registry.js +62 -0
- package/dist/composition/runtime.d.ts +63 -0
- package/dist/composition/runtime.js +155 -0
- package/dist/core/approval/service.d.ts +17 -0
- package/dist/core/approval/service.js +129 -0
- package/dist/core/attach/bootstrap.d.ts +102 -0
- package/dist/core/attach/bootstrap.js +178 -0
- package/dist/core/attach/init.d.ts +29 -0
- package/dist/core/attach/init.js +100 -0
- package/dist/core/attach/setup-plan.d.ts +125 -0
- package/dist/core/attach/setup-plan.js +177 -0
- package/dist/core/attach/setup.d.ts +40 -0
- package/dist/core/attach/setup.js +140 -0
- package/dist/core/binding/types.d.ts +93 -0
- package/dist/core/binding/types.js +74 -0
- package/dist/core/distribution/release.d.ts +15 -0
- package/dist/core/distribution/release.js +27 -0
- package/dist/core/distribution/runtime-install.d.ts +50 -0
- package/dist/core/distribution/runtime-install.js +90 -0
- package/dist/core/distribution/runtime-select.d.ts +74 -0
- package/dist/core/distribution/runtime-select.js +149 -0
- package/dist/core/execution/executor.d.ts +51 -0
- package/dist/core/execution/executor.js +106 -0
- package/dist/core/execution/grant.d.ts +42 -0
- package/dist/core/execution/grant.js +78 -0
- package/dist/core/model/entities.d.ts +872 -0
- package/dist/core/model/entities.js +285 -0
- package/dist/core/model/ids.d.ts +41 -0
- package/dist/core/model/ids.js +50 -0
- package/dist/core/model/transitions.d.ts +26 -0
- package/dist/core/model/transitions.js +124 -0
- package/dist/core/monitor/coverage.d.ts +111 -0
- package/dist/core/monitor/coverage.js +129 -0
- package/dist/core/monitor/engine.d.ts +134 -0
- package/dist/core/monitor/engine.js +574 -0
- package/dist/core/monitor/health-alerts.d.ts +35 -0
- package/dist/core/monitor/health-alerts.js +88 -0
- package/dist/core/monitor/investigation.d.ts +82 -0
- package/dist/core/monitor/investigation.js +232 -0
- package/dist/core/monitor/observation.d.ts +79 -0
- package/dist/core/monitor/observation.js +110 -0
- package/dist/core/monitor/relevance.d.ts +47 -0
- package/dist/core/monitor/relevance.js +100 -0
- package/dist/core/monitor/signals.d.ts +59 -0
- package/dist/core/monitor/signals.js +105 -0
- package/dist/core/operator/local-operator.d.ts +60 -0
- package/dist/core/operator/local-operator.js +82 -0
- package/dist/core/operator/preflight.d.ts +60 -0
- package/dist/core/operator/preflight.js +139 -0
- package/dist/core/operator/proceed.d.ts +98 -0
- package/dist/core/operator/proceed.js +167 -0
- package/dist/core/operator/progress.d.ts +124 -0
- package/dist/core/operator/progress.js +135 -0
- package/dist/core/operator/render.d.ts +34 -0
- package/dist/core/operator/render.js +170 -0
- package/dist/core/operator/runtime-binding.d.ts +61 -0
- package/dist/core/operator/runtime-binding.js +22 -0
- package/dist/core/policy/ownership.d.ts +44 -0
- package/dist/core/policy/ownership.js +46 -0
- package/dist/core/policy/policy.d.ts +99 -0
- package/dist/core/policy/policy.js +147 -0
- package/dist/core/policy/remote-freeze.d.ts +101 -0
- package/dist/core/policy/remote-freeze.js +151 -0
- package/dist/core/policy/scope.d.ts +23 -0
- package/dist/core/policy/scope.js +93 -0
- package/dist/core/presentation/digest.d.ts +98 -0
- package/dist/core/presentation/digest.js +160 -0
- package/dist/core/resolver/load.d.ts +121 -0
- package/dist/core/resolver/load.js +246 -0
- package/dist/core/resolver/render.d.ts +4 -0
- package/dist/core/resolver/render.js +62 -0
- package/dist/core/resolver/resolve.d.ts +42 -0
- package/dist/core/resolver/resolve.js +51 -0
- package/dist/core/resolver/version.d.ts +22 -0
- package/dist/core/resolver/version.js +49 -0
- package/dist/core/runtime/audit.d.ts +374 -0
- package/dist/core/runtime/audit.js +454 -0
- package/dist/core/runtime/claims.d.ts +115 -0
- package/dist/core/runtime/claims.js +153 -0
- package/dist/core/runtime/closure.d.ts +73 -0
- package/dist/core/runtime/closure.js +162 -0
- package/dist/core/runtime/controller.d.ts +40 -0
- package/dist/core/runtime/controller.js +121 -0
- package/dist/core/runtime/escalation.d.ts +188 -0
- package/dist/core/runtime/escalation.js +322 -0
- package/dist/core/runtime/execution-state.d.ts +43 -0
- package/dist/core/runtime/execution-state.js +81 -0
- package/dist/core/runtime/front.d.ts +95 -0
- package/dist/core/runtime/front.js +144 -0
- package/dist/core/runtime/orchestrator.d.ts +54 -0
- package/dist/core/runtime/orchestrator.js +98 -0
- package/dist/core/runtime/query.d.ts +184 -0
- package/dist/core/runtime/query.js +213 -0
- package/dist/core/runtime/report.d.ts +33 -0
- package/dist/core/runtime/report.js +108 -0
- package/dist/core/runtime/session.d.ts +156 -0
- package/dist/core/runtime/session.js +281 -0
- package/dist/core/runtime/store-ops.d.ts +22 -0
- package/dist/core/runtime/store-ops.js +26 -0
- package/dist/core/view/build-view.d.ts +36 -0
- package/dist/core/view/build-view.js +131 -0
- package/dist/core/view/decision-view.d.ts +564 -0
- package/dist/core/view/decision-view.js +103 -0
- package/dist/core/workspace/identity.d.ts +82 -0
- package/dist/core/workspace/identity.js +133 -0
- package/dist/core/workspace/index-store.d.ts +140 -0
- package/dist/core/workspace/index-store.js +125 -0
- package/dist/core/workspace/migrate.d.ts +73 -0
- package/dist/core/workspace/migrate.js +123 -0
- package/dist/core/workspace/resolve.d.ts +34 -0
- package/dist/core/workspace/resolve.js +89 -0
- package/dist/ports/adapter.d.ts +35 -0
- package/dist/ports/adapter.js +12 -0
- package/dist/ports/approval.d.ts +98 -0
- package/dist/ports/approval.js +7 -0
- package/dist/ports/change-context.d.ts +22 -0
- package/dist/ports/change-context.js +8 -0
- package/dist/ports/event-source.d.ts +42 -0
- package/dist/ports/event-source.js +6 -0
- package/dist/ports/inventory.d.ts +53 -0
- package/dist/ports/inventory.js +12 -0
- package/dist/ports/presentation.d.ts +62 -0
- package/dist/ports/presentation.js +13 -0
- package/dist/ports/renderer.d.ts +23 -0
- package/dist/ports/renderer.js +7 -0
- package/dist/ports/resource-context.d.ts +51 -0
- package/dist/ports/resource-context.js +9 -0
- package/dist/ports/scm.d.ts +40 -0
- package/dist/ports/scm.js +6 -0
- package/dist/ports/state-store.d.ts +97 -0
- package/dist/ports/state-store.js +12 -0
- package/dist/presets/balanced.json +8 -0
- package/dist/presets/conservative.json +9 -0
- package/dist/presets/lightweight.json +8 -0
- package/dist/profiles/example-team/profile.json +101 -0
- package/dist/profiles/pilot-local/profile.json +56 -0
- package/dist/schemas/profile.d.ts +631 -0
- package/dist/schemas/profile.js +287 -0
- package/package.json +55 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IdentityMap } from '../adapters/local/identity.ts';
|
|
2
|
+
export declare const IDENTITY_FILE = "identities.json";
|
|
3
|
+
/**
|
|
4
|
+
* `{ "controller-a": ["local:colosair", "mattermost:@colosair"] }` 형태.
|
|
5
|
+
* 이름과 채널만 담고 비밀은 담지 않는다 (OM §4.5).
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadIdentityMap(root: string): Promise<IdentityMap>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 승인자 매핑 로드. Profile/Override에서 읽어오는 정식 경로는 B-10에서 붙고,
|
|
2
|
+
// 그때까지는 `.asc/identities.json` 하나를 본다.
|
|
3
|
+
//
|
|
4
|
+
// 파일이 없으면 승인은 전부 거절된다. 매핑이 없다는 것은 "아직 누구도 승인자로 지정되지
|
|
5
|
+
// 않았다"는 뜻이고, 그 상태에서 통과시키면 검증이 있으나 마나다 — 없으면 막는 쪽이
|
|
6
|
+
// 기본값이어야 한다 (OM §11.6).
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export const IDENTITY_FILE = 'identities.json';
|
|
10
|
+
/**
|
|
11
|
+
* `{ "controller-a": ["local:colosair", "mattermost:@colosair"] }` 형태.
|
|
12
|
+
* 이름과 채널만 담고 비밀은 담지 않는다 (OM §4.5).
|
|
13
|
+
*/
|
|
14
|
+
export async function loadIdentityMap(root) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(await readFile(join(root, IDENTITY_FILE), 'utf8'));
|
|
17
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
18
|
+
return {};
|
|
19
|
+
const map = {};
|
|
20
|
+
for (const [approver, ids] of Object.entries(parsed)) {
|
|
21
|
+
if (Array.isArray(ids))
|
|
22
|
+
map[approver] = ids.filter((id) => typeof id === 'string');
|
|
23
|
+
}
|
|
24
|
+
return map;
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (error.code === 'ENOENT')
|
|
28
|
+
return {};
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { OwnershipMap } from '../core/policy/ownership.ts';
|
|
2
|
+
import type { EventObservation } from '../core/monitor/engine.ts';
|
|
3
|
+
import type { ChangeContextPort } from '../ports/change-context.ts';
|
|
4
|
+
import type { RawEvent } from '../ports/event-source.ts';
|
|
5
|
+
export type ObservationDeps = {
|
|
6
|
+
/** 무엇이 어디서 바뀌었는가. 이 통로가 없으면 관련성 판정 자체가 서지 않는다. */
|
|
7
|
+
change: ChangeContextPort;
|
|
8
|
+
/** Profile이 선언한 책임 지도 (C-04 §6). */
|
|
9
|
+
ownership?: OwnershipMap;
|
|
10
|
+
/** 이 사람이 맡은 역할들 (User Override). 선언이 없으면 ownership 근거는 성립하지 않는다. */
|
|
11
|
+
myRoles?: readonly string[];
|
|
12
|
+
/** 정본이 사는 경로. contract 근거와 canonical 신호의 기준이다. */
|
|
13
|
+
canonicalPaths?: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* 사건 하나에 대한 관찰을 만든다.
|
|
17
|
+
*
|
|
18
|
+
* 실패는 전부 "모른다"로 접는다 — 관찰이 감지를 막지 않는다. 외부 조회가 흔들려서
|
|
19
|
+
* 판단 대기함이 비면 그건 조회 실패가 아니라 **감지 실패**로 보이기 때문이다.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildEventObservation(deps: ObservationDeps): (event: RawEvent) => Promise<EventObservation>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Observation Builder — Monitor가 사건마다 "밖에서 알아 올" 사실을 조립한다 (C-07 §2~§4).
|
|
2
|
+
//
|
|
3
|
+
// Engine은 `observe`를 받으면 신호·관련성·억제를 켜고, 받지 않으면 예전처럼 신호만으로
|
|
4
|
+
// 판정한다. 그 dependency를 **production 조립에서 실제로 채우는 것**이 이 파일의 전부다.
|
|
5
|
+
// 판정 자체는 Core(evaluateRelevance·ObservationLedger)가 하고 여기서는 하지 않는다.
|
|
6
|
+
//
|
|
7
|
+
// 가장 중요한 규칙은 **모르면 만들지 않는다**이다:
|
|
8
|
+
//
|
|
9
|
+
// change를 못 읽음 → 아무것도 만들지 않는다 (신호만으로 판정)
|
|
10
|
+
// 경로를 일부만 읽음 → 관련성·신호 금지, 실질 변화 마커만 살린다
|
|
11
|
+
// 구조적 판정 근거 없음 → 관련성 자체를 만들지 않는다
|
|
12
|
+
//
|
|
13
|
+
// 마지막 줄이 핵심이다. evaluateRelevance는 구조적 근거가 하나도 없으면 actual=LOW를
|
|
14
|
+
// 내고 그건 Shadow(숨김)가 된다. 그러니 근거를 댈 수 없는 상태에서 관련성을 만들어
|
|
15
|
+
// 넘기면 **모든 사건이 조용히 숨는다** — 근거 없이 숨기는 것이 가장 나쁜 결과다.
|
|
16
|
+
/** 역할 선언이 실제 ownership으로 풀리는가. 오타·미선언 역할은 근거가 아니다. */
|
|
17
|
+
function ownedPaths(map, roles) {
|
|
18
|
+
if (!map || !roles?.length)
|
|
19
|
+
return [];
|
|
20
|
+
return roles.flatMap((role) => map[role]?.paths ?? []);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 사건 하나에 대한 관찰을 만든다.
|
|
24
|
+
*
|
|
25
|
+
* 실패는 전부 "모른다"로 접는다 — 관찰이 감지를 막지 않는다. 외부 조회가 흔들려서
|
|
26
|
+
* 판단 대기함이 비면 그건 조회 실패가 아니라 **감지 실패**로 보이기 때문이다.
|
|
27
|
+
*/
|
|
28
|
+
export function buildEventObservation(deps) {
|
|
29
|
+
const owned = ownedPaths(deps.ownership, deps.myRoles);
|
|
30
|
+
const canonicalPaths = deps.canonicalPaths?.length ? deps.canonicalPaths : undefined;
|
|
31
|
+
return async (event) => {
|
|
32
|
+
let change;
|
|
33
|
+
try {
|
|
34
|
+
change = await deps.change.getChange(event.reference);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// 못 읽은 것을 "안 바뀌었다"로 쓰지 않는다.
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
// 변경요청이 아니거나 못 읽었다. 둘 다 "모른다"이므로 마커도 만들지 않는다 —
|
|
41
|
+
// 없는 마커를 지어내면 다음 회차가 그것과 대조해 실질 변화를 잘못 판정한다.
|
|
42
|
+
if (change.missing)
|
|
43
|
+
return {};
|
|
44
|
+
const revisionMarker = change.revisionMarker || undefined;
|
|
45
|
+
// 경로를 일부만 봤다면 "내 영역은 안 바뀌었다"고 말할 수 없다. 관련성도 신호도
|
|
46
|
+
// 만들지 않되, 유효한 실질 변화 마커까지 버리지는 않는다 (중복 억제는 계속 선다).
|
|
47
|
+
if (change.truncated)
|
|
48
|
+
return revisionMarker ? { revisionMarker } : {};
|
|
49
|
+
const changedPaths = change.changedPaths;
|
|
50
|
+
if (changedPaths.length === 0)
|
|
51
|
+
return revisionMarker ? { revisionMarker } : {};
|
|
52
|
+
// 구조적 판정 근거가 최소 하나 성립할 때만 관련성을 만든다.
|
|
53
|
+
// A. ownership — 역할 선언이 실제 경로로 풀린다
|
|
54
|
+
// B. contract — 정본 경로가 있어 접촉 여부를 판정할 수 있다
|
|
55
|
+
// 둘 다 없으면 관련성을 만들지 않는다 (신호만으로 판정 = 기존 동작).
|
|
56
|
+
const canJudge = owned.length > 0 || canonicalPaths !== undefined;
|
|
57
|
+
return {
|
|
58
|
+
...(revisionMarker ? { revisionMarker } : {}),
|
|
59
|
+
signal: { changedPaths, ...(canonicalPaths ? { canonicalPaths } : {}) },
|
|
60
|
+
...(canJudge
|
|
61
|
+
? {
|
|
62
|
+
relevance: {
|
|
63
|
+
...(deps.ownership ? { ownership: deps.ownership } : {}),
|
|
64
|
+
...(deps.myRoles?.length ? { myRoles: deps.myRoles } : {}),
|
|
65
|
+
changedPaths,
|
|
66
|
+
...(canonicalPaths ? { canonicalPaths } : {}),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BindingPlan } from '../core/binding/types.ts';
|
|
2
|
+
import type { Adapter, DiscoveryContext } from '../ports/adapter.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 이 빌드에 들어 있는 adapter. 여기 없는 것은 존재하지 않는다.
|
|
5
|
+
*
|
|
6
|
+
* 목록에 있다고 켜지는 것이 아니다 — discover가 후보를 찾지 못하거나 probe가 쓸 수
|
|
7
|
+
* 없다고 하면 그 갈래는 조립되지 않는다 (C-09 §7).
|
|
8
|
+
*/
|
|
9
|
+
export declare function defaultAdapters(): Adapter[];
|
|
10
|
+
export type BindingRole = {
|
|
11
|
+
adapterId: string;
|
|
12
|
+
resource: string;
|
|
13
|
+
role: string;
|
|
14
|
+
};
|
|
15
|
+
export type ComposeInput = {
|
|
16
|
+
context: DiscoveryContext;
|
|
17
|
+
/** 없으면 이 빌드의 기본 목록. 테스트가 다른 조합을 물릴 수 있다. */
|
|
18
|
+
adapters?: readonly Adapter[];
|
|
19
|
+
/**
|
|
20
|
+
* Profile이 선언한 역할 배정. **역할은 사람이 정한다** — 어느 binding이 code-primary인지
|
|
21
|
+
* ASC가 추론하지 않는다 (C-09 §3.1).
|
|
22
|
+
*/
|
|
23
|
+
roles?: readonly BindingRole[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* describe → discover → probe → BindingPlan.
|
|
27
|
+
*
|
|
28
|
+
* provider 목록을 순회하는 코드가 아니다 — 등록된 adapter가 스스로 후보를 찾고,
|
|
29
|
+
* adapter가 없으면 그 갈래는 애초에 없다 (C-09 §7).
|
|
30
|
+
*/
|
|
31
|
+
export declare function composeBindings(input: ComposeInput): Promise<BindingPlan>;
|
|
32
|
+
/** 설치된 adapter가 정적으로 선언하는 것. 네트워크도 파일 접근도 없다. */
|
|
33
|
+
export declare function describeAll(adapters?: readonly Adapter[]): import("../core/binding/types.ts").AdapterDescriptor[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Composition Root — 실제 Adapter를 아는 유일한 자리 (C-09 §6).
|
|
2
|
+
//
|
|
3
|
+
// **이 파일은 `core/` 밖이다.** Core는 adapter를 import하지 않고, adapter id로 분기하지도
|
|
4
|
+
// 않는다. 여기서 조립한 BindingPlan만 Core로 넘어간다.
|
|
5
|
+
//
|
|
6
|
+
// 정적 registry로 충분하다 (C-09 §6.2). 동적 로더·marketplace·서명·버전 협상은 만들지
|
|
7
|
+
// 않는다 — 두세 개의 실제 adapter에서 같은 구조가 반복되는 것을 본 뒤에 검토한다.
|
|
8
|
+
// 지금 만들면 쓰이지 않는 확장점의 유지 비용만 남는다.
|
|
9
|
+
import { GitHubAdapter } from "../adapters/github/adapter.js";
|
|
10
|
+
import { GitLabAdapter } from "../adapters/gitlab/adapter.js";
|
|
11
|
+
import { JamAdapter } from "../adapters/jam/adapter.js";
|
|
12
|
+
/**
|
|
13
|
+
* 이 빌드에 들어 있는 adapter. 여기 없는 것은 존재하지 않는다.
|
|
14
|
+
*
|
|
15
|
+
* 목록에 있다고 켜지는 것이 아니다 — discover가 후보를 찾지 못하거나 probe가 쓸 수
|
|
16
|
+
* 없다고 하면 그 갈래는 조립되지 않는다 (C-09 §7).
|
|
17
|
+
*/
|
|
18
|
+
export function defaultAdapters() {
|
|
19
|
+
return [new GitHubAdapter(), new GitLabAdapter(), new JamAdapter()];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* describe → discover → probe → BindingPlan.
|
|
23
|
+
*
|
|
24
|
+
* provider 목록을 순회하는 코드가 아니다 — 등록된 adapter가 스스로 후보를 찾고,
|
|
25
|
+
* adapter가 없으면 그 갈래는 애초에 없다 (C-09 §7).
|
|
26
|
+
*/
|
|
27
|
+
export async function composeBindings(input) {
|
|
28
|
+
const adapters = input.adapters ?? defaultAdapters();
|
|
29
|
+
const bindings = [];
|
|
30
|
+
const runtimes = [];
|
|
31
|
+
for (const adapter of adapters) {
|
|
32
|
+
// 도구가 쓸 수 있는가와 이 프로젝트가 그 도구에 붙어 있는가는 다른 사실이다.
|
|
33
|
+
// 합치면 사람이 "설치할 일인지 붙일 일인지"를 알 수 없다.
|
|
34
|
+
if (adapter.runtime) {
|
|
35
|
+
const status = await adapter
|
|
36
|
+
.runtime(input.context)
|
|
37
|
+
.catch((error) => ({ state: 'UNAVAILABLE', detail: String(error) }));
|
|
38
|
+
runtimes.push({ adapterId: adapter.describe().id, ...status });
|
|
39
|
+
}
|
|
40
|
+
const candidates = await adapter.discover(input.context).catch(() => []);
|
|
41
|
+
for (const candidate of candidates) {
|
|
42
|
+
// probe가 터지는 것과 "안 된다"는 다르다. 예외를 UNAVAILABLE로 옮겨 적되
|
|
43
|
+
// 이유를 남긴다 — 조용히 후보에서 빼면 왜 안 보이는지 알 수 없다.
|
|
44
|
+
const result = await adapter
|
|
45
|
+
.probe(candidate, input.context)
|
|
46
|
+
.catch((error) => ({ state: 'UNAVAILABLE', detail: String(error) }));
|
|
47
|
+
const role = input.roles?.find((r) => r.adapterId === candidate.adapterId && r.resource === candidate.resource)?.role;
|
|
48
|
+
bindings.push({
|
|
49
|
+
...candidate,
|
|
50
|
+
...(result.provides ? { provides: result.provides } : {}),
|
|
51
|
+
state: result.state,
|
|
52
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
53
|
+
...(role ? { role } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { bindings, ...(runtimes.length > 0 ? { runtimes } : {}) };
|
|
58
|
+
}
|
|
59
|
+
/** 설치된 adapter가 정적으로 선언하는 것. 네트워크도 파일 접근도 없다. */
|
|
60
|
+
export function describeAll(adapters = defaultAdapters()) {
|
|
61
|
+
return adapters.map((adapter) => adapter.describe());
|
|
62
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Capability, BindingPlan, ResolvedBinding } from '../core/binding/types.ts';
|
|
2
|
+
import type { ChangeContextPort } from '../ports/change-context.ts';
|
|
3
|
+
import type { EventSource } from '../ports/event-source.ts';
|
|
4
|
+
import type { InventoryPort } from '../ports/inventory.ts';
|
|
5
|
+
import type { ResourceContextPort } from '../ports/resource-context.ts';
|
|
6
|
+
import type { ScmPort } from '../ports/scm.ts';
|
|
7
|
+
export type RuntimePorts = {
|
|
8
|
+
eventSource?: EventSource;
|
|
9
|
+
scm?: ScmPort;
|
|
10
|
+
inventory?: InventoryPort;
|
|
11
|
+
resourceContext?: ResourceContextPort;
|
|
12
|
+
changeContext?: ChangeContextPort;
|
|
13
|
+
/** 무엇을 왜 못 만들었는지. 조용히 빠지면 사람이 이유를 알 수 없다. */
|
|
14
|
+
unavailable: string[];
|
|
15
|
+
};
|
|
16
|
+
export type BuildInput = {
|
|
17
|
+
plan: BindingPlan;
|
|
18
|
+
/**
|
|
19
|
+
* capability별로 어느 역할이 맡는지 (C-09 §4). Profile의 `bindings[]` 선언이 여기 온다.
|
|
20
|
+
* 역할을 주면 같은 capability를 여럿이 제공해도 갈리지 않는다.
|
|
21
|
+
*/
|
|
22
|
+
roles?: Partial<Record<Capability, string>>;
|
|
23
|
+
/** JAM 같은 도구형 adapter를 조립하기 위한 통로. 없으면 그 갈래는 만들지 않는다. */
|
|
24
|
+
jam?: {
|
|
25
|
+
command: string;
|
|
26
|
+
args?: readonly string[];
|
|
27
|
+
cwd?: string;
|
|
28
|
+
/**
|
|
29
|
+
* JQL 날짜 리터럴을 해석할 Jira 계정 timezone(IANA). 선언하지 않으면 adapter가
|
|
30
|
+
* UTC로 읽는다 — 기계의 timezone을 쓰지 않는다(adapters/jam/ports.ts).
|
|
31
|
+
*/
|
|
32
|
+
timezone?: string;
|
|
33
|
+
};
|
|
34
|
+
/** canonical source id → ref. Profile이 준다. */
|
|
35
|
+
sourceRefs?: Readonly<Record<string, {
|
|
36
|
+
ref: string;
|
|
37
|
+
}>>;
|
|
38
|
+
/** 이벤트 조회 페이지 크기. */
|
|
39
|
+
perPage?: number;
|
|
40
|
+
/** 자격 조회 통로 주입점(테스트용). adapter id를 받아 그 adapter의 자격을 돌려준다. */
|
|
41
|
+
findToken?: (adapterId: string) => Promise<string | null>;
|
|
42
|
+
/** 이 binding이 어느 주소를 가리키는지. 발견 단계가 알아낸 값을 그대로 잇는다. */
|
|
43
|
+
endpointFor?: (binding: ResolvedBinding) => string | undefined;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* capability가 필요한 자리마다 어느 binding이 맡을지 정해 Port를 만든다.
|
|
47
|
+
*
|
|
48
|
+
* 후보가 갈리면 만들지 않는다 — `AMBIGUOUS_BINDING`은 사람이 정할 문제이고, 여기서 하나를
|
|
49
|
+
* 고르면 그 선택을 아무도 보지 못한다 (C-09 §4.2).
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* Profile이 선언한 역할 배정을 capability별 역할로 옮긴다 (C-09 §3.1·§4).
|
|
53
|
+
*
|
|
54
|
+
* **추론하지 않는다.** 선언된 binding이 그 capability를 제공한다고 plan에 적혀 있을 때만
|
|
55
|
+
* 그 역할을 쓴다. 둘 이상이 같은 capability를 제공하면 고르지 않고 비워 둔다 —
|
|
56
|
+
* 그러면 resolve가 AMBIGUOUS로 표면화한다. 여기서 하나를 고르면 사람이 그걸 못 본다.
|
|
57
|
+
*/
|
|
58
|
+
export declare function rolesFor(plan: BindingPlan, declared: readonly {
|
|
59
|
+
role: string;
|
|
60
|
+
adapter: string;
|
|
61
|
+
resource: string;
|
|
62
|
+
}[]): Partial<Record<Capability, string>>;
|
|
63
|
+
export declare function buildRuntimePorts(input: BuildInput): Promise<RuntimePorts>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Runtime 조립 — Binding에서 실제 Port 구현을 만든다 (C-09 §6).
|
|
2
|
+
//
|
|
3
|
+
// CLI가 adapter를 직접 `new` 하면 Surface가 provider를 아는 지점이 흩어지고, adapter를
|
|
4
|
+
// 바꿀 때마다 호출부를 전부 고쳐야 한다. 조립을 여기 한 곳에 모으면 교체가 이 파일의
|
|
5
|
+
// 변경으로 끝난다 — 그것이 "provider 교체는 Binding 교체" 의 실제 모습이다.
|
|
6
|
+
//
|
|
7
|
+
// **Core는 이 파일을 import하지 않는다.** 방향은 언제나 Composition → Core다.
|
|
8
|
+
import { resolveCapability } from "../core/binding/types.js";
|
|
9
|
+
import { GitHubClient, discoverToken } from "../adapters/github/client.js";
|
|
10
|
+
import { GitHubChangeContext, GitHubInventory, GitHubResourceContext } from "../adapters/github/context.js";
|
|
11
|
+
import { GitHubEventSource } from "../adapters/github/event-source.js";
|
|
12
|
+
import { GitHubScm } from "../adapters/github/scm.js";
|
|
13
|
+
import { GitLabClient, discoverToken as discoverGitLabToken } from "../adapters/gitlab/client.js";
|
|
14
|
+
import { GitLabChangeContext, GitLabEventSource, GitLabInventory, GitLabResourceContext, } from "../adapters/gitlab/ports.js";
|
|
15
|
+
import { JamMcpClient } from "../adapters/jam/mcp-client.js";
|
|
16
|
+
import { JamEventSource } from "../adapters/jam/event-source.js";
|
|
17
|
+
import { JamInventory, JamResourceContext } from "../adapters/jam/ports.js";
|
|
18
|
+
const FACTORIES = {
|
|
19
|
+
gitlab(binding, input, token) {
|
|
20
|
+
// 자체 호스팅이 흔하다. 어디를 가리키는지는 발견 단계가 이미 알아냈으므로 같은 값을 쓴다.
|
|
21
|
+
const baseUrl = input.endpointFor?.(binding);
|
|
22
|
+
const client = new GitLabClient({ token, ...(baseUrl ? { baseUrl } : {}) });
|
|
23
|
+
const project = binding.resource;
|
|
24
|
+
return {
|
|
25
|
+
eventSource: new GitLabEventSource({ client, project, perPage: input.perPage ?? 30 }),
|
|
26
|
+
inventory: new GitLabInventory({ client, project }),
|
|
27
|
+
resourceContext: new GitLabResourceContext({ client, project }),
|
|
28
|
+
changeContext: new GitLabChangeContext({ client, project }),
|
|
29
|
+
// canonical·외부 write 통로는 아직 없다. 없는 것을 있는 척하지 않는다.
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
github(binding, input, token) {
|
|
33
|
+
const client = new GitHubClient({ token });
|
|
34
|
+
const repo = binding.resource;
|
|
35
|
+
return {
|
|
36
|
+
eventSource: new GitHubEventSource({ client, repo, perPage: input.perPage ?? 30 }),
|
|
37
|
+
scm: new GitHubScm({ client, defaultRepo: repo, sourceRefs: input.sourceRefs ?? {} }),
|
|
38
|
+
inventory: new GitHubInventory({ client, defaultRepo: repo }),
|
|
39
|
+
resourceContext: new GitHubResourceContext({ client, defaultRepo: repo }),
|
|
40
|
+
changeContext: new GitHubChangeContext({ client, defaultRepo: repo }),
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
jam(binding, input) {
|
|
44
|
+
// JAM은 토큰을 받지 않는다 — 자격은 도구가 자기 안에서 관리하고 ASC는 상태만 읽는다.
|
|
45
|
+
if (!input.jam)
|
|
46
|
+
return {};
|
|
47
|
+
const client = new JamMcpClient({
|
|
48
|
+
command: input.jam.command,
|
|
49
|
+
...(input.jam.args ? { args: input.jam.args } : {}),
|
|
50
|
+
...(input.jam.cwd ? { cwd: input.jam.cwd } : {}),
|
|
51
|
+
});
|
|
52
|
+
const projectKey = binding.resource;
|
|
53
|
+
const timezone = input.jam.timezone;
|
|
54
|
+
const inventory = new JamInventory({ client, projectKey, ...(timezone ? { timezone } : {}) });
|
|
55
|
+
return {
|
|
56
|
+
inventory,
|
|
57
|
+
resourceContext: new JamResourceContext({
|
|
58
|
+
client,
|
|
59
|
+
projectKey,
|
|
60
|
+
...(timezone ? { timezone } : {}),
|
|
61
|
+
}),
|
|
62
|
+
// 푸시가 아니라 updated-since 증분 조회다 (C-07 §1.1) — adapter 주석 참조
|
|
63
|
+
eventSource: new JamEventSource({ inventory }),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
/** 이 adapter는 토큰 없이 조립된다. 자격은 도구가 자기 안에서 진다. */
|
|
68
|
+
const TOKENLESS = new Set(['jam']);
|
|
69
|
+
/**
|
|
70
|
+
* capability와 Port의 대응. **이 표가 없으면 조립이 덮어쓰기가 된다** —
|
|
71
|
+
* 두 binding이 각각 다른 capability를 맡았는데 나중 것이 앞 것의 Port까지 밀어낸다.
|
|
72
|
+
*/
|
|
73
|
+
const PORT_OF = {
|
|
74
|
+
'observe.delta': 'eventSource',
|
|
75
|
+
'inventory.enumerate': 'inventory',
|
|
76
|
+
'context.change': 'changeContext',
|
|
77
|
+
'context.resource': 'resourceContext',
|
|
78
|
+
'canonical.read': 'scm',
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* capability가 필요한 자리마다 어느 binding이 맡을지 정해 Port를 만든다.
|
|
82
|
+
*
|
|
83
|
+
* 후보가 갈리면 만들지 않는다 — `AMBIGUOUS_BINDING`은 사람이 정할 문제이고, 여기서 하나를
|
|
84
|
+
* 고르면 그 선택을 아무도 보지 못한다 (C-09 §4.2).
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Profile이 선언한 역할 배정을 capability별 역할로 옮긴다 (C-09 §3.1·§4).
|
|
88
|
+
*
|
|
89
|
+
* **추론하지 않는다.** 선언된 binding이 그 capability를 제공한다고 plan에 적혀 있을 때만
|
|
90
|
+
* 그 역할을 쓴다. 둘 이상이 같은 capability를 제공하면 고르지 않고 비워 둔다 —
|
|
91
|
+
* 그러면 resolve가 AMBIGUOUS로 표면화한다. 여기서 하나를 고르면 사람이 그걸 못 본다.
|
|
92
|
+
*/
|
|
93
|
+
export function rolesFor(plan, declared) {
|
|
94
|
+
const roles = {};
|
|
95
|
+
const tagged = plan.bindings.filter((b) => b.role !== undefined);
|
|
96
|
+
const capabilities = new Set(tagged.flatMap((b) => [...b.provides]));
|
|
97
|
+
for (const capability of capabilities) {
|
|
98
|
+
const owners = new Set(tagged
|
|
99
|
+
.filter((b) => b.provides.includes(capability))
|
|
100
|
+
.filter((b) => declared.some((d) => d.adapter === b.adapterId && d.resource === b.resource))
|
|
101
|
+
.map((b) => b.role));
|
|
102
|
+
if (owners.size === 1)
|
|
103
|
+
roles[capability] = [...owners][0];
|
|
104
|
+
}
|
|
105
|
+
return roles;
|
|
106
|
+
}
|
|
107
|
+
export async function buildRuntimePorts(input) {
|
|
108
|
+
const ports = { unavailable: [] };
|
|
109
|
+
// 자격은 adapter마다 다른 곳에 있다. Core는 이 사실을 모르고, 여기서만 안다.
|
|
110
|
+
const findToken = input.findToken ??
|
|
111
|
+
(async (adapterId) => adapterId === 'gitlab' ? discoverGitLabToken() : await discoverToken());
|
|
112
|
+
// capability마다 따로 푼다. 한 binding이 여럿을 제공해도, 서로 다른 binding이 나눠
|
|
113
|
+
// 맡아도 같은 경로로 조립된다 — 어느 갈래가 어디서 왔는지가 Port마다 정확해야 한다.
|
|
114
|
+
const wanted = Object.keys(PORT_OF);
|
|
115
|
+
const built = new Map();
|
|
116
|
+
for (const capability of wanted) {
|
|
117
|
+
const role = input.roles?.[capability];
|
|
118
|
+
const resolution = resolveCapability(input.plan, { capability, ...(role ? { role } : {}) });
|
|
119
|
+
if (resolution.kind !== 'RESOLVED') {
|
|
120
|
+
ports.unavailable.push(resolution.kind === 'AMBIGUOUS'
|
|
121
|
+
? `${capability}: 후보가 둘 이상이라 고르지 않았다 (${resolution.candidates
|
|
122
|
+
.map((c) => `${c.adapterId}:${c.resource}`)
|
|
123
|
+
.join(', ')}) — Profile bindings 로 역할을 정하라`
|
|
124
|
+
: `${capability}: ${resolution.detail}`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const binding = resolution.binding;
|
|
128
|
+
const key = `${binding.adapterId}:${binding.resource}`;
|
|
129
|
+
let made = built.get(key);
|
|
130
|
+
if (!made) {
|
|
131
|
+
const factory = FACTORIES[binding.adapterId];
|
|
132
|
+
if (!factory) {
|
|
133
|
+
ports.unavailable.push(`${binding.adapterId}: 이 빌드에 조립 경로가 없다`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const token = TOKENLESS.has(binding.adapterId) ? '' : await findToken(binding.adapterId);
|
|
137
|
+
if (token === null) {
|
|
138
|
+
ports.unavailable.push(`${binding.adapterId}: 자격이 없어 외부 조회를 만들지 않았다`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
made = factory(binding, input, token);
|
|
142
|
+
built.set(key, made);
|
|
143
|
+
}
|
|
144
|
+
// **그 capability에 해당하는 Port만 가져온다.** 통째로 assign하면 다른 binding이
|
|
145
|
+
// 맡기로 한 갈래까지 덮어쓴다 — multi-binding이 조용히 single-binding이 된다.
|
|
146
|
+
const portKey = PORT_OF[capability];
|
|
147
|
+
const port = made[portKey];
|
|
148
|
+
if (port === undefined) {
|
|
149
|
+
ports.unavailable.push(`${capability}: ${binding.adapterId} 가 이 갈래를 만들지 않았다`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
Object.assign(ports, { [portKey]: port });
|
|
153
|
+
}
|
|
154
|
+
return ports;
|
|
155
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ApprovalDecision } from '../model/entities.ts';
|
|
2
|
+
import type { ApprovalChannel, DecisionOutcome, DecisionSink, IdentityBinding } from '../../ports/approval.ts';
|
|
3
|
+
import type { ScmPort } from '../../ports/scm.ts';
|
|
4
|
+
import type { StateStore } from '../../ports/state-store.ts';
|
|
5
|
+
export type ApprovalDeps = {
|
|
6
|
+
store: StateStore;
|
|
7
|
+
identity: IdentityBinding;
|
|
8
|
+
/** 결정 후 표시를 갱신할 채널들. 실패해도 결정은 유효하다 (C-01 §9). */
|
|
9
|
+
channels?: readonly ApprovalChannel[];
|
|
10
|
+
scm?: ScmPort;
|
|
11
|
+
now?: () => string;
|
|
12
|
+
};
|
|
13
|
+
export declare class ApprovalService implements DecisionSink {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(deps: ApprovalDeps);
|
|
16
|
+
submit(decision: ApprovalDecision): Promise<DecisionOutcome>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// 결정 제출 경로 — 사람의 명시적 의사표현을 ApprovalDecision으로 받아 상태를 옮긴다.
|
|
2
|
+
//
|
|
3
|
+
// 조회(LocalOperator)와 결정을 다른 파일에 둔 이유는 문 하나 차이다. 조회 객체가
|
|
4
|
+
// submit을 갖고 있으면 요청을 읽던 Agent가 그대로 승인까지 이어가기 쉬워지고,
|
|
5
|
+
// "AI 판단 ≠ Controller Decision"이 구조가 아니라 습관에 기대게 된다 (C-01 §5).
|
|
6
|
+
//
|
|
7
|
+
// **신뢰 경계 (현 단계 계약)**: `asc inbox decide` 같은 결정 표면을 사람이 조작하는
|
|
8
|
+
// 표면으로 간주하고, Identity Binding으로 *승인자 identity*를 검증한다. 이것은
|
|
9
|
+
// "지금 키보드를 두드린 것이 사람이다"라는 기술적 증명이 아니다 — 셸을 쓸 수 있는
|
|
10
|
+
// Agent는 매핑된 이름을 댈 수 있다. OS 인증·서명·대화형 확인 같은 강한 local
|
|
11
|
+
// authentication은 별도 후속 범위이며, 그전까지 이 계층이 막는 것은 "권한자로 지정되지
|
|
12
|
+
// 않은 이름의 결정"까지다. 매핑에 없는 이름은 거절되고 시도가 History에 남는다 (OM §11.6).
|
|
13
|
+
import { transitionRequest } from "../model/transitions.js";
|
|
14
|
+
import { assembleView, assess, buildOverlay } from "../view/build-view.js";
|
|
15
|
+
/**
|
|
16
|
+
* 사람의 선택이 요청을 어느 상태로 옮기는가.
|
|
17
|
+
* `revise`가 APPROVED로 가는 것은 "고쳐서 승인한다"이지 별도 상태가 아니다 —
|
|
18
|
+
* 무엇을 고쳤는지는 revision에 남고, 실제 실행은 그 revision을 payload로 쓴다.
|
|
19
|
+
*/
|
|
20
|
+
const TARGET_STATUS = {
|
|
21
|
+
approve: 'APPROVED',
|
|
22
|
+
revise: 'APPROVED',
|
|
23
|
+
queue: 'QUEUED',
|
|
24
|
+
defer: 'DEFERRED',
|
|
25
|
+
dismiss: 'DISMISSED',
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* 아직 사람의 판단을 더 받을 수 있는 상태.
|
|
29
|
+
* 보류는 결정을 미룬 것이지 끝낸 것이 아니므로 다시 판단할 수 있어야 한다 (OM §11.2).
|
|
30
|
+
* 판정 기준을 "결정 기록이 있는가"로 두면 defer가 사실상 terminal이 되어버린다 —
|
|
31
|
+
* 기록은 남기되 상태로 판단한다.
|
|
32
|
+
*/
|
|
33
|
+
const REOPENABLE = new Set(['AWAITING_APPROVAL', 'DEFERRED']);
|
|
34
|
+
export class ApprovalService {
|
|
35
|
+
#store;
|
|
36
|
+
#identity;
|
|
37
|
+
#channels;
|
|
38
|
+
#scm;
|
|
39
|
+
#now;
|
|
40
|
+
constructor(deps) {
|
|
41
|
+
this.#store = deps.store;
|
|
42
|
+
this.#identity = deps.identity;
|
|
43
|
+
this.#channels = deps.channels ?? [];
|
|
44
|
+
this.#scm = deps.scm;
|
|
45
|
+
this.#now = deps.now ?? (() => new Date().toISOString());
|
|
46
|
+
}
|
|
47
|
+
async submit(decision) {
|
|
48
|
+
const request = await this.#store.get('request', decision.requestId);
|
|
49
|
+
if (!request)
|
|
50
|
+
return { ok: false, reason: 'NOT_FOUND' };
|
|
51
|
+
const authorized = await this.#identity.verify({
|
|
52
|
+
channel: decision.channel,
|
|
53
|
+
actor: decision.actor,
|
|
54
|
+
authorizedApprover: request.authorizedApprover,
|
|
55
|
+
});
|
|
56
|
+
if (!authorized) {
|
|
57
|
+
// 거절로 끝내지 않고 남긴다 — 누가 승인하려 했는지는 나중에 물을 수 있는 질문이다
|
|
58
|
+
await this.#store.appendHistory({
|
|
59
|
+
at: this.#now(),
|
|
60
|
+
actor: decision.actor,
|
|
61
|
+
kind: 'decision_rejected',
|
|
62
|
+
ref: request.id,
|
|
63
|
+
detail: `unauthorized via ${decision.channel} (${decision.kind})`,
|
|
64
|
+
});
|
|
65
|
+
return { ok: false, reason: 'FORBIDDEN_ACTOR' };
|
|
66
|
+
}
|
|
67
|
+
if (!request.allowedDecisions.includes(decision.kind))
|
|
68
|
+
return { ok: false, reason: 'NOT_ALLOWED_DECISION' };
|
|
69
|
+
if (request.expiresAt && request.expiresAt <= decision.decidedAt)
|
|
70
|
+
return { ok: false, reason: 'EXPIRED' };
|
|
71
|
+
// 읽은 뒤 상황이 바뀌었는지부터 본다. 끝난 요청과 그 사이 한 번 더 움직인 요청은
|
|
72
|
+
// 사용자에게 다른 이야기이므로 이유를 갈라서 돌려준다.
|
|
73
|
+
if (!REOPENABLE.has(request.status)) {
|
|
74
|
+
return { ok: false, reason: 'ALREADY_DECIDED', view: await this.#view(request) };
|
|
75
|
+
}
|
|
76
|
+
if (request.version !== decision.expectedVersion) {
|
|
77
|
+
return { ok: false, reason: 'STALE', view: await this.#view(request) };
|
|
78
|
+
}
|
|
79
|
+
const next = transitionRequest(request, TARGET_STATUS[decision.kind], 'controller', {
|
|
80
|
+
decision: {
|
|
81
|
+
kind: decision.kind,
|
|
82
|
+
actor: decision.actor,
|
|
83
|
+
channel: decision.channel,
|
|
84
|
+
...(decision.revision !== undefined ? { revision: decision.revision } : {}),
|
|
85
|
+
decidedAt: decision.decidedAt,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const saved = await this.#store.compareAndSet('request', request.id, decision.expectedVersion, next);
|
|
89
|
+
if (!saved.ok) {
|
|
90
|
+
if (saved.reason === 'NOT_FOUND')
|
|
91
|
+
return { ok: false, reason: 'NOT_FOUND' };
|
|
92
|
+
// 다른 채널이 먼저 들어왔다. 그쪽이 요청을 끝냈다면 이미 결정된 것이고,
|
|
93
|
+
// 아직 판단을 더 받을 수 있는 상태라면 다시 보고 결정할 일이다.
|
|
94
|
+
const view = await this.#view(saved.current);
|
|
95
|
+
return REOPENABLE.has(saved.current.status)
|
|
96
|
+
? { ok: false, reason: 'STALE', view }
|
|
97
|
+
: { ok: false, reason: 'ALREADY_DECIDED', view };
|
|
98
|
+
}
|
|
99
|
+
await this.#store.appendHistory({
|
|
100
|
+
at: decision.decidedAt,
|
|
101
|
+
actor: decision.actor,
|
|
102
|
+
kind: 'decision',
|
|
103
|
+
ref: request.id,
|
|
104
|
+
detail: `${decision.kind} via ${decision.channel}${decision.revision ? ' (revised)' : ''}`,
|
|
105
|
+
});
|
|
106
|
+
const view = await this.#view(saved.entity);
|
|
107
|
+
await this.#notifyChannels(view);
|
|
108
|
+
return { ok: true, view };
|
|
109
|
+
}
|
|
110
|
+
/** 표시 갱신은 best-effort다 — 채널이 죽어도 결정은 이미 확정됐고, 낡은 버튼은 CAS가 막는다. */
|
|
111
|
+
async #notifyChannels(view) {
|
|
112
|
+
for (const channel of this.#channels) {
|
|
113
|
+
try {
|
|
114
|
+
await channel.update(view);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// 채널 사정은 canonical state에 영향을 주지 않는다
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async #view(request) {
|
|
122
|
+
const overlay = await buildOverlay(request, {
|
|
123
|
+
control: await this.#store.getControlState(),
|
|
124
|
+
observedAt: this.#now(),
|
|
125
|
+
...(this.#scm ? { scm: this.#scm } : {}),
|
|
126
|
+
});
|
|
127
|
+
return assembleView(request, await assess(request, overlay, this.#scm), overlay);
|
|
128
|
+
}
|
|
129
|
+
}
|