@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,42 @@
|
|
|
1
|
+
// Local Presentation — 마지막 안전망 (C-08 §1.3).
|
|
2
|
+
//
|
|
3
|
+
// 외부 채널이 하나도 없어도, 전부 실패해도, 판단 요청은 사라지지 않아야 한다. 그 보장을
|
|
4
|
+
// 지는 것이 이 adapter다 — 여기까지 실패하면 그건 로컬 환경이 망가진 것이다.
|
|
5
|
+
//
|
|
6
|
+
// 세 능력을 다 제공한다: 묶음도 보이고, 급한 것도 따로 보이고, 결정도 여기서 받는다
|
|
7
|
+
// (결정 자체는 기존 승인 경로가 처리한다 — 이 adapter는 보이는 데까지다).
|
|
8
|
+
import { renderDigest } from "../../core/presentation/digest.js";
|
|
9
|
+
export class LocalPresentation {
|
|
10
|
+
id = 'local';
|
|
11
|
+
capabilities = new Set([
|
|
12
|
+
'presentation.digest',
|
|
13
|
+
'presentation.priority',
|
|
14
|
+
'approval.interactive',
|
|
15
|
+
]);
|
|
16
|
+
#write;
|
|
17
|
+
constructor(deps = {}) {
|
|
18
|
+
this.#write = deps.write ?? ((line) => console.log(line));
|
|
19
|
+
}
|
|
20
|
+
async presentDigest(batch) {
|
|
21
|
+
// 계획 없이 batch만 받았을 때도 같은 모양으로 보인다 — 표시 형식이 한 곳에만 있게.
|
|
22
|
+
const plan = {
|
|
23
|
+
urgent: batch.groups.find((g) => g.priority === 'P0')?.items.slice() ?? [],
|
|
24
|
+
batch,
|
|
25
|
+
skipped: {
|
|
26
|
+
alreadyDecided: batch.suppressed?.alreadyDecided ?? 0,
|
|
27
|
+
alreadyDelivered: 0,
|
|
28
|
+
shadow: batch.suppressed?.shadow ?? 0,
|
|
29
|
+
},
|
|
30
|
+
// Port는 batch만 받는다 — 감시 상태는 계획을 만든 쪽이 알고, 채널은 모른다
|
|
31
|
+
health: [],
|
|
32
|
+
};
|
|
33
|
+
for (const line of renderDigest(plan))
|
|
34
|
+
this.#write(line);
|
|
35
|
+
return { ok: true };
|
|
36
|
+
}
|
|
37
|
+
async presentUrgent(item) {
|
|
38
|
+
this.#write(`🔴 ${item.requestId} ${item.reference} ${item.title}`);
|
|
39
|
+
this.#write(` 지금 확인이 필요하다 — asc inbox show ${item.requestId}`);
|
|
40
|
+
return { ok: true };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EntityKind } from '../../ports/state-store.ts';
|
|
2
|
+
export declare const CONTROL_STATE_FILE = "state.md";
|
|
3
|
+
export declare const HISTORY_FILE = "monitor/log-current.md";
|
|
4
|
+
export declare const VIEWS_DIR = "monitor/views";
|
|
5
|
+
export declare const ADAPTER_SCOPE_DIR = "adapters";
|
|
6
|
+
/**
|
|
7
|
+
* 파일명으로 쓸 수 없는 문자를 바꾼다. event key의 `comment:531245`가 대표적이다.
|
|
8
|
+
* 되돌릴 필요는 없다 — 원본 id는 파일 안에 그대로 들어 있고, 목록은 파일을 읽어 만든다.
|
|
9
|
+
*/
|
|
10
|
+
export declare function toFileName(id: string): string;
|
|
11
|
+
export declare function entityDir(root: string, kind: EntityKind): string;
|
|
12
|
+
export declare function entityFile(root: string, kind: EntityKind, id: string): string;
|
|
13
|
+
export declare function archiveDir(root: string, kind: EntityKind): string;
|
|
14
|
+
/** bootstrap 시 만들어 두는 디렉터리 목록. */
|
|
15
|
+
export declare function allDirs(root: string): string[];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `.asc/` 파일 배치. 이건 Markdown Adapter의 표현일 뿐 Core Contract가 아니다 —
|
|
2
|
+
// Core는 entity kind와 id만 알고, 어느 디렉터리에 놓이는지는 모른다 (OM §7.0).
|
|
3
|
+
// 배치 근거: OM §7.1.
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** entity 한 종류가 사는 디렉터리. 파일 하나가 entity 하나다 (OM §7.2). */
|
|
6
|
+
const ENTITY_DIR = {
|
|
7
|
+
session: 'sessions/active',
|
|
8
|
+
request: 'monitor/inbox',
|
|
9
|
+
grant: 'monitor/grants',
|
|
10
|
+
queueItem: 'monitor/queue',
|
|
11
|
+
event: 'monitor/events',
|
|
12
|
+
};
|
|
13
|
+
export const CONTROL_STATE_FILE = 'state.md';
|
|
14
|
+
export const HISTORY_FILE = 'monitor/log-current.md';
|
|
15
|
+
export const VIEWS_DIR = 'monitor/views';
|
|
16
|
+
export const ADAPTER_SCOPE_DIR = 'adapters';
|
|
17
|
+
/**
|
|
18
|
+
* 파일명으로 쓸 수 없는 문자를 바꾼다. event key의 `comment:531245`가 대표적이다.
|
|
19
|
+
* 되돌릴 필요는 없다 — 원본 id는 파일 안에 그대로 들어 있고, 목록은 파일을 읽어 만든다.
|
|
20
|
+
*/
|
|
21
|
+
export function toFileName(id) {
|
|
22
|
+
return id.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
23
|
+
}
|
|
24
|
+
export function entityDir(root, kind) {
|
|
25
|
+
return join(root, ENTITY_DIR[kind]);
|
|
26
|
+
}
|
|
27
|
+
export function entityFile(root, kind, id) {
|
|
28
|
+
return join(entityDir(root, kind), `${toFileName(id)}.md`);
|
|
29
|
+
}
|
|
30
|
+
/** 끝난 entity가 가는 곳. 종류마다 자기 보관함을 갖는다 (OM §7.4). */
|
|
31
|
+
const ARCHIVE_DIR = {
|
|
32
|
+
session: 'sessions/archive',
|
|
33
|
+
request: 'monitor/archive/inbox',
|
|
34
|
+
grant: 'monitor/archive/grants',
|
|
35
|
+
queueItem: 'monitor/archive/queue',
|
|
36
|
+
event: 'monitor/archive/events',
|
|
37
|
+
};
|
|
38
|
+
export function archiveDir(root, kind) {
|
|
39
|
+
return join(root, ARCHIVE_DIR[kind]);
|
|
40
|
+
}
|
|
41
|
+
/** bootstrap 시 만들어 두는 디렉터리 목록. */
|
|
42
|
+
export function allDirs(root) {
|
|
43
|
+
return [
|
|
44
|
+
...Object.values(ENTITY_DIR).map((dir) => join(root, dir)),
|
|
45
|
+
join(root, VIEWS_DIR),
|
|
46
|
+
join(root, ADAPTER_SCOPE_DIR),
|
|
47
|
+
...Object.values(ARCHIVE_DIR).map((dir) => join(root, dir)),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EntityKind, EntityMap } from '../../ports/state-store.ts';
|
|
2
|
+
export declare class ParseError extends Error {
|
|
3
|
+
file: string;
|
|
4
|
+
constructor(file: string, detail: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function serializeEntity<K extends EntityKind>(kind: K, entity: EntityMap[K]): string;
|
|
7
|
+
export declare function parseEntity(text: string, file?: string): unknown;
|
|
8
|
+
/** 현재 판단 대기 목록. 원본에서 언제든 다시 만들 수 있는 Derived View다 (OM §7.3). */
|
|
9
|
+
export declare function renderInboxView(requests: readonly EntityMap['request'][]): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// entity ↔ 파일 내용.
|
|
2
|
+
//
|
|
3
|
+
// 한 파일이 두 독자를 섬긴다: 기계는 HTML 주석 안의 JSON 블록을 읽고, 사람은 그 아래
|
|
4
|
+
// Markdown 본문을 읽는다. 정본은 JSON 쪽이고 본문은 매번 다시 그려지는 projection이다 —
|
|
5
|
+
// 사람이 본문만 고쳐 봐야 다음 저장에서 덮인다는 뜻이고, 그래서 본문을 정본으로
|
|
6
|
+
// 삼으려는 유혹이 애초에 생기지 않는다.
|
|
7
|
+
//
|
|
8
|
+
// 왕복 무손실은 JSON 블록이 보장한다. 본문 렌더링이 아무리 성기어도 데이터는 잃지 않는다.
|
|
9
|
+
const OPEN = '<!-- asc:entity';
|
|
10
|
+
const CLOSE = '-->';
|
|
11
|
+
export class ParseError extends Error {
|
|
12
|
+
file;
|
|
13
|
+
constructor(file, detail) {
|
|
14
|
+
super(`${file}: ${detail}`);
|
|
15
|
+
this.file = file;
|
|
16
|
+
this.name = 'ParseError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function serializeEntity(kind, entity) {
|
|
20
|
+
const json = JSON.stringify(entity, null, 2);
|
|
21
|
+
return `${OPEN}\n${json}\n${CLOSE}\n\n${renderBody(kind, entity)}\n`;
|
|
22
|
+
}
|
|
23
|
+
export function parseEntity(text, file = '<memory>') {
|
|
24
|
+
const start = text.indexOf(OPEN);
|
|
25
|
+
if (start === -1)
|
|
26
|
+
throw new ParseError(file, 'missing asc:entity block');
|
|
27
|
+
const end = text.indexOf(CLOSE, start + OPEN.length);
|
|
28
|
+
if (end === -1)
|
|
29
|
+
throw new ParseError(file, 'unterminated asc:entity block');
|
|
30
|
+
const json = text.slice(start + OPEN.length, end).trim();
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(json);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new ParseError(file, `invalid JSON — ${error.message}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// ── 사람이 읽는 본문 ────────────────────────────────────────────────────────
|
|
39
|
+
// 상태를 눈으로 좇을 수 있을 만큼만 그린다. 완전한 표현은 Renderer Port의 몫이다.
|
|
40
|
+
function renderBody(kind, entity) {
|
|
41
|
+
const lines = [];
|
|
42
|
+
switch (kind) {
|
|
43
|
+
case 'session': {
|
|
44
|
+
const s = entity;
|
|
45
|
+
lines.push(`# ${s.id} — ${s.role}`, '', `Status: ${s.status} (v${s.version})`, `Goal: ${s.goal}`);
|
|
46
|
+
if (s.owner)
|
|
47
|
+
lines.push(`Owner: ${s.owner}`);
|
|
48
|
+
if (s.writeBoundary?.length)
|
|
49
|
+
lines.push(`Write Boundary: ${s.writeBoundary.join(', ')}`);
|
|
50
|
+
for (const domain of s.decisionDomains ?? []) {
|
|
51
|
+
lines.push(`Decision: ${domain} → ${s.decisionAuthority?.[domain] ?? '(Profile ownership)'}`);
|
|
52
|
+
}
|
|
53
|
+
if (s.dependencies?.length)
|
|
54
|
+
lines.push(`Dependency: ${s.dependencies.join(', ')}`);
|
|
55
|
+
if (s.policyExceptions?.length)
|
|
56
|
+
lines.push(`Policy Exception: ${s.policyExceptions.join(', ')}`);
|
|
57
|
+
if (s.doneCriteria.length > 0) {
|
|
58
|
+
lines.push('', '## Done Criteria', ...s.doneCriteria.map((c) => `- ${c}`));
|
|
59
|
+
}
|
|
60
|
+
if (s.checkpoint)
|
|
61
|
+
lines.push('', '## CHECKPOINT', s.checkpoint.position, `다음: ${s.checkpoint.nextAction}`);
|
|
62
|
+
if (s.handoff) {
|
|
63
|
+
lines.push('', '## HANDOFF', `DONE: ${s.handoff.done.join(', ')}`, `NEXT: ${s.handoff.next}`);
|
|
64
|
+
if (s.handoff.unresolved?.length)
|
|
65
|
+
lines.push(`UNRESOLVED: ${s.handoff.unresolved.join(' / ')}`);
|
|
66
|
+
}
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
case 'request': {
|
|
70
|
+
const r = entity;
|
|
71
|
+
lines.push(`# ${r.id} · ${r.priority} · ${r.source.reference}`, '', `Status: ${r.status} (v${r.version})`, `Detected: ${r.detectedAt}`, '', '## 상황', r.situation);
|
|
72
|
+
if (r.draft)
|
|
73
|
+
lines.push('', '## 답변 초안', r.draft);
|
|
74
|
+
if (r.snapshot?.length) {
|
|
75
|
+
lines.push('', '## 정본 스냅샷', ...r.snapshot.map((s) => `- ${s.sourceId} @ ${s.baseline}`));
|
|
76
|
+
}
|
|
77
|
+
if (r.decision)
|
|
78
|
+
lines.push('', '## 처리 기록', `${r.decision.kind} — ${r.decision.actor} (${r.decision.channel})`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case 'grant': {
|
|
82
|
+
const g = entity;
|
|
83
|
+
lines.push(`# ${g.id} — ${g.action}`, '', `Status: ${g.status} (v${g.version})`, `Target: ${g.target}`, `Request: ${g.requestId}`, `Expires: ${g.expiresAt}`);
|
|
84
|
+
if (g.resultRef)
|
|
85
|
+
lines.push(`Result: ${g.resultRef}`);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case 'queueItem': {
|
|
89
|
+
const q = entity;
|
|
90
|
+
lines.push(`# ${q.id} — ${q.title}`, '', `State: ${q.state} (v${q.version})`);
|
|
91
|
+
if (q.sessionId)
|
|
92
|
+
lines.push(`Session: ${q.sessionId}`);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case 'event': {
|
|
96
|
+
const e = entity;
|
|
97
|
+
lines.push(`# ${e.eventKey}`, '', `${e.type} · ${e.suggestedPriority} · ${e.processing} (v${e.version})`, `Detected: ${e.detectedAt}`, `Inbox 후보: ${e.inboxCandidate ? 'yes' : 'no'}`);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
/** 현재 판단 대기 목록. 원본에서 언제든 다시 만들 수 있는 Derived View다 (OM §7.3). */
|
|
104
|
+
export function renderInboxView(requests) {
|
|
105
|
+
const pending = requests.filter((r) => r.status === 'AWAITING_APPROVAL' || r.status === 'APPROVED');
|
|
106
|
+
const lines = [
|
|
107
|
+
'<!-- asc:view — 자동 생성물. 직접 고치지 마세요. -->',
|
|
108
|
+
'',
|
|
109
|
+
'# Inbox — 판단 대기',
|
|
110
|
+
'',
|
|
111
|
+
pending.length === 0 ? '_대기 중인 요청 없음_' : '| Request | 우선순위 | 상태 | 감지 | 제목 |',
|
|
112
|
+
];
|
|
113
|
+
if (pending.length > 0) {
|
|
114
|
+
lines.push('|---|---|---|---|---|');
|
|
115
|
+
for (const r of [...pending].sort((a, b) => a.detectedAt.localeCompare(b.detectedAt))) {
|
|
116
|
+
lines.push(`| ${r.id} | ${r.priority} | ${r.status} | ${r.detectedAt} | ${r.title} |`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lines.join('\n') + '\n';
|
|
120
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ControlState } from '../../core/model/entities.ts';
|
|
2
|
+
import { type CasResult, type CreateResult, type EntityKind, type EntityMap, type HistoryEntry, type ListFilter, type ScopedStore, type StateStore } from '../../ports/state-store.ts';
|
|
3
|
+
export declare class MarkdownStateStore implements StateStore {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(root: string);
|
|
6
|
+
/** `.asc/` 위치. 감추지 않는다 — 디버깅과 direct-read fallback이 이 경로를 쓴다 (C-01 §12). */
|
|
7
|
+
get root(): string;
|
|
8
|
+
/** `.asc/` 뼈대를 만든다. 이미 있으면 그대로 쓴다. */
|
|
9
|
+
static open(root: string): Promise<MarkdownStateStore>;
|
|
10
|
+
get<K extends EntityKind>(kind: K, id: string): Promise<EntityMap[K] | null>;
|
|
11
|
+
list<K extends EntityKind>(kind: K, filter?: ListFilter<K>): Promise<EntityMap[K][]>;
|
|
12
|
+
create<K extends EntityKind>(kind: K, entity: EntityMap[K]): Promise<CreateResult<EntityMap[K]>>;
|
|
13
|
+
compareAndSet<K extends EntityKind>(kind: K, id: string, expectedVersion: number, next: EntityMap[K]): Promise<CasResult<EntityMap[K]>>;
|
|
14
|
+
/** 파일을 보관 디렉터리로 옮긴다. 내용은 그대로다 — 요약도 삭제도 하지 않는다. */
|
|
15
|
+
archive<K extends EntityKind>(kind: K, id: string): Promise<boolean>;
|
|
16
|
+
getControlState(): Promise<ControlState>;
|
|
17
|
+
setControlState(expectedVersion: number, next: ControlState): Promise<CasResult<ControlState>>;
|
|
18
|
+
appendHistory(entry: HistoryEntry): Promise<void>;
|
|
19
|
+
readHistory(limit?: number): Promise<HistoryEntry[]>;
|
|
20
|
+
scope(adapterId: string): ScopedStore;
|
|
21
|
+
/** 지워도 되고 언제든 다시 만들 수 있다 (OM §7.3). */
|
|
22
|
+
refreshViews(): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Markdown State Store — 기본 State Store 구현 (OM §7.0~7.2).
|
|
2
|
+
//
|
|
3
|
+
// 동시성 전략은 두 층이다:
|
|
4
|
+
// 생성 — O_EXCL(`wx`)이 파일시스템 차원에서 중복을 막는다. 락이 필요 없다.
|
|
5
|
+
// 갱신 — 읽기·검사·쓰기가 한 덩어리여야 하므로 락 파일로 임계구역을 만들고,
|
|
6
|
+
// 내용은 임시 파일에 쓴 뒤 rename으로 갈아 끼운다. rename은 원자적이라
|
|
7
|
+
// 절반만 쓰인 파일이 남지 않는다.
|
|
8
|
+
//
|
|
9
|
+
// 락 대기와 CAS 충돌은 다른 사건이다. 락은 순간적이므로 잠깐 스핀해 기다리고,
|
|
10
|
+
// version 불일치는 "그 사이 누가 결정했다"는 사실이므로 기다리지 않고 그대로 보고한다.
|
|
11
|
+
import { constants } from 'node:fs';
|
|
12
|
+
import { access, appendFile, mkdir, open, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
15
|
+
import { ENTITY_KEY, } from "../../ports/state-store.js";
|
|
16
|
+
import { ADAPTER_SCOPE_DIR, CONTROL_STATE_FILE, archiveDir, HISTORY_FILE, VIEWS_DIR, allDirs, entityDir, entityFile, toFileName, } from "./layout.js";
|
|
17
|
+
import { parseEntity, renderInboxView, serializeEntity } from "./serialize.js";
|
|
18
|
+
const LOCK_SPIN_MS = 5;
|
|
19
|
+
const LOCK_ATTEMPTS = 200;
|
|
20
|
+
/** 이보다 오래된 락은 죽은 프로세스가 남긴 것으로 보고 회수한다. */
|
|
21
|
+
const LOCK_STALE_MS = 30_000;
|
|
22
|
+
const EMPTY_CONTROL_STATE = {
|
|
23
|
+
version: 0,
|
|
24
|
+
activeSessions: [],
|
|
25
|
+
writeBoundaryOccupancy: [],
|
|
26
|
+
awaitingController: [],
|
|
27
|
+
controllerAttention: [],
|
|
28
|
+
};
|
|
29
|
+
let tmpCounter = 0;
|
|
30
|
+
async function writeAtomic(file, content) {
|
|
31
|
+
const tmp = `${file}.tmp.${process.pid}.${tmpCounter++}`;
|
|
32
|
+
await writeFile(tmp, content, 'utf8');
|
|
33
|
+
await rename(tmp, file);
|
|
34
|
+
}
|
|
35
|
+
async function exists(file) {
|
|
36
|
+
try {
|
|
37
|
+
await access(file, constants.F_OK);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class MarkdownStateStore {
|
|
45
|
+
#root;
|
|
46
|
+
constructor(root) {
|
|
47
|
+
this.#root = root;
|
|
48
|
+
}
|
|
49
|
+
/** `.asc/` 위치. 감추지 않는다 — 디버깅과 direct-read fallback이 이 경로를 쓴다 (C-01 §12). */
|
|
50
|
+
get root() {
|
|
51
|
+
return this.#root;
|
|
52
|
+
}
|
|
53
|
+
/** `.asc/` 뼈대를 만든다. 이미 있으면 그대로 쓴다. */
|
|
54
|
+
static async open(root) {
|
|
55
|
+
for (const dir of allDirs(root))
|
|
56
|
+
await mkdir(dir, { recursive: true });
|
|
57
|
+
return new MarkdownStateStore(root);
|
|
58
|
+
}
|
|
59
|
+
// ── 락 ────────────────────────────────────────────────────────────────────
|
|
60
|
+
async #acquire(file) {
|
|
61
|
+
const lock = `${file}.lock`;
|
|
62
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
const handle = await open(lock, 'wx');
|
|
65
|
+
await handle.close();
|
|
66
|
+
return async () => {
|
|
67
|
+
await rm(lock, { force: true });
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code !== 'EEXIST')
|
|
72
|
+
throw error;
|
|
73
|
+
// ponytail: mtime 하나로 판단하는 stale 회수. 한 호스트에서만 쓰는 동안은 충분하고,
|
|
74
|
+
// 여러 호스트가 같은 .asc/를 공유하게 되면 임차(lease) 방식으로 올려야 한다.
|
|
75
|
+
try {
|
|
76
|
+
const age = Date.now() - (await stat(lock)).mtimeMs;
|
|
77
|
+
if (age > LOCK_STALE_MS)
|
|
78
|
+
await rm(lock, { force: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// 그 사이 풀렸다 — 다음 시도에서 잡으면 된다
|
|
82
|
+
}
|
|
83
|
+
await delay(LOCK_SPIN_MS);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`could not acquire lock for ${file}`);
|
|
87
|
+
}
|
|
88
|
+
async #withLock(file, fn) {
|
|
89
|
+
const release = await this.#acquire(file);
|
|
90
|
+
try {
|
|
91
|
+
return await fn();
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ── entity ────────────────────────────────────────────────────────────────
|
|
98
|
+
async #read(kind, file) {
|
|
99
|
+
try {
|
|
100
|
+
const text = await readFile(file, 'utf8');
|
|
101
|
+
return parseEntity(text, file);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === 'ENOENT')
|
|
105
|
+
return null;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async get(kind, id) {
|
|
110
|
+
return this.#read(kind, entityFile(this.#root, kind, id));
|
|
111
|
+
}
|
|
112
|
+
async list(kind, filter = {}) {
|
|
113
|
+
const dir = entityDir(this.#root, kind);
|
|
114
|
+
let names;
|
|
115
|
+
try {
|
|
116
|
+
names = await readdir(dir);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error.code === 'ENOENT')
|
|
120
|
+
return [];
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
const rows = [];
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
// 락·임시 파일은 entity가 아니다
|
|
126
|
+
if (!name.endsWith('.md'))
|
|
127
|
+
continue;
|
|
128
|
+
const entity = await this.#read(kind, join(dir, name));
|
|
129
|
+
if (entity)
|
|
130
|
+
rows.push(entity);
|
|
131
|
+
}
|
|
132
|
+
const where = filter.where;
|
|
133
|
+
const matched = where
|
|
134
|
+
? rows.filter((row) => Object.entries(where).every(([f, v]) => row[f] === v))
|
|
135
|
+
: rows;
|
|
136
|
+
return matched.slice(0, filter.limit ?? matched.length);
|
|
137
|
+
}
|
|
138
|
+
async create(kind, entity) {
|
|
139
|
+
const id = String(entity[ENTITY_KEY[kind]]);
|
|
140
|
+
const file = entityFile(this.#root, kind, id);
|
|
141
|
+
try {
|
|
142
|
+
// 'wx'가 원자적 배타 생성이라 락 없이도 중복이 걸린다
|
|
143
|
+
await writeFile(file, serializeEntity(kind, entity), { encoding: 'utf8', flag: 'wx' });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code !== 'EEXIST')
|
|
147
|
+
throw error;
|
|
148
|
+
const current = (await this.#read(kind, file));
|
|
149
|
+
return { ok: false, reason: 'ALREADY_EXISTS', current };
|
|
150
|
+
}
|
|
151
|
+
if (kind === 'request')
|
|
152
|
+
await this.refreshViews();
|
|
153
|
+
return { ok: true, entity };
|
|
154
|
+
}
|
|
155
|
+
async compareAndSet(kind, id, expectedVersion, next) {
|
|
156
|
+
if (next.version !== expectedVersion + 1) {
|
|
157
|
+
throw new Error(`CAS contract: next.version must be ${expectedVersion + 1}, got ${next.version}`);
|
|
158
|
+
}
|
|
159
|
+
const file = entityFile(this.#root, kind, id);
|
|
160
|
+
const result = await this.#withLock(file, async () => {
|
|
161
|
+
const current = await this.#read(kind, file);
|
|
162
|
+
if (!current)
|
|
163
|
+
return { ok: false, reason: 'NOT_FOUND' };
|
|
164
|
+
if (current.version !== expectedVersion)
|
|
165
|
+
return { ok: false, reason: 'VERSION_CONFLICT', current };
|
|
166
|
+
await writeAtomic(file, serializeEntity(kind, next));
|
|
167
|
+
return { ok: true, entity: next };
|
|
168
|
+
});
|
|
169
|
+
if (result.ok && kind === 'request')
|
|
170
|
+
await this.refreshViews();
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
/** 파일을 보관 디렉터리로 옮긴다. 내용은 그대로다 — 요약도 삭제도 하지 않는다. */
|
|
174
|
+
async archive(kind, id) {
|
|
175
|
+
const from = entityFile(this.#root, kind, id);
|
|
176
|
+
if (!(await exists(from)))
|
|
177
|
+
return false;
|
|
178
|
+
const dir = archiveDir(this.#root, kind);
|
|
179
|
+
await mkdir(dir, { recursive: true });
|
|
180
|
+
await rename(from, join(dir, `${toFileName(id)}.md`));
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
// ── Control State ─────────────────────────────────────────────────────────
|
|
184
|
+
async getControlState() {
|
|
185
|
+
const file = join(this.#root, CONTROL_STATE_FILE);
|
|
186
|
+
try {
|
|
187
|
+
return parseEntity(await readFile(file, 'utf8'), file);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (error.code === 'ENOENT')
|
|
191
|
+
return { ...EMPTY_CONTROL_STATE };
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async setControlState(expectedVersion, next) {
|
|
196
|
+
if (next.version !== expectedVersion + 1) {
|
|
197
|
+
throw new Error(`CAS contract: next.version must be ${expectedVersion + 1}, got ${next.version}`);
|
|
198
|
+
}
|
|
199
|
+
const file = join(this.#root, CONTROL_STATE_FILE);
|
|
200
|
+
return this.#withLock(file, async () => {
|
|
201
|
+
const current = await this.getControlState();
|
|
202
|
+
if (current.version !== expectedVersion)
|
|
203
|
+
return { ok: false, reason: 'VERSION_CONFLICT', current };
|
|
204
|
+
await writeAtomic(file, renderControlState(next));
|
|
205
|
+
return { ok: true, entity: next };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
// ── History ───────────────────────────────────────────────────────────────
|
|
209
|
+
async appendHistory(entry) {
|
|
210
|
+
const file = join(this.#root, HISTORY_FILE);
|
|
211
|
+
const line = [entry.at, entry.actor, entry.kind, entry.ref, entry.detail ?? ''].join(' | ');
|
|
212
|
+
// append-only. 요약하거나 줄이지 않는다 — 오래된 것은 archive로 옮길 뿐이다 (OM §7.4).
|
|
213
|
+
await this.#withLock(file, () => appendFile(file, `${line}\n`, 'utf8'));
|
|
214
|
+
}
|
|
215
|
+
async readHistory(limit) {
|
|
216
|
+
const file = join(this.#root, HISTORY_FILE);
|
|
217
|
+
if (!(await exists(file)))
|
|
218
|
+
return [];
|
|
219
|
+
const rows = (await readFile(file, 'utf8'))
|
|
220
|
+
.split('\n')
|
|
221
|
+
.filter((line) => line.trim().length > 0)
|
|
222
|
+
.map((line) => {
|
|
223
|
+
// detail에 구분자가 들어 있어도 잃지 않도록 앞 네 칸만 자르고 나머지는 합친다
|
|
224
|
+
const [at, actor, kind, ref, ...rest] = line.split(' | ');
|
|
225
|
+
const detail = rest.join(' | ');
|
|
226
|
+
return { at: at, actor: actor, kind: kind, ref: ref, ...(detail ? { detail } : {}) };
|
|
227
|
+
});
|
|
228
|
+
return limit ? rows.slice(-limit) : rows;
|
|
229
|
+
}
|
|
230
|
+
// ── Adapter scope ─────────────────────────────────────────────────────────
|
|
231
|
+
scope(adapterId) {
|
|
232
|
+
const dir = join(this.#root, ADAPTER_SCOPE_DIR, toFileName(adapterId));
|
|
233
|
+
const fileFor = (key) => join(dir, `${toFileName(key)}.json`);
|
|
234
|
+
return {
|
|
235
|
+
async get(key) {
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(await readFile(fileFor(key), 'utf8')).value;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error.code === 'ENOENT')
|
|
241
|
+
return null;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
async set(key, value) {
|
|
246
|
+
await mkdir(dir, { recursive: true });
|
|
247
|
+
// key도 함께 적는다 — 파일명은 문자를 바꿔 저장하므로 되돌릴 수 없다
|
|
248
|
+
await writeAtomic(fileFor(key), JSON.stringify({ key, value }, null, 2));
|
|
249
|
+
},
|
|
250
|
+
async setIfAbsent(key, value) {
|
|
251
|
+
await mkdir(dir, { recursive: true });
|
|
252
|
+
try {
|
|
253
|
+
// 'wx'가 파일시스템 차원의 배타 생성이라, 프로세스가 여럿이어도 하나만 성공한다
|
|
254
|
+
await writeFile(fileFor(key), JSON.stringify({ key, value }, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code === 'EEXIST')
|
|
259
|
+
return false;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
async delete(key) {
|
|
264
|
+
await rm(fileFor(key), { force: true });
|
|
265
|
+
},
|
|
266
|
+
async keys(prefix = '') {
|
|
267
|
+
let names;
|
|
268
|
+
try {
|
|
269
|
+
names = await readdir(dir);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (error.code === 'ENOENT')
|
|
273
|
+
return [];
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
const keys = [];
|
|
277
|
+
for (const name of names) {
|
|
278
|
+
if (!name.endsWith('.json'))
|
|
279
|
+
continue;
|
|
280
|
+
const { key } = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
281
|
+
if (typeof key === 'string' && key.startsWith(prefix))
|
|
282
|
+
keys.push(key);
|
|
283
|
+
}
|
|
284
|
+
return keys;
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// ── Derived View ──────────────────────────────────────────────────────────
|
|
289
|
+
/** 지워도 되고 언제든 다시 만들 수 있다 (OM §7.3). */
|
|
290
|
+
async refreshViews() {
|
|
291
|
+
const requests = await this.list('request');
|
|
292
|
+
await mkdir(join(this.#root, VIEWS_DIR), { recursive: true });
|
|
293
|
+
await writeAtomic(join(this.#root, VIEWS_DIR, 'inbox.md'), renderInboxView(requests));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function renderControlState(state) {
|
|
297
|
+
const lines = [
|
|
298
|
+
'<!-- asc:entity',
|
|
299
|
+
JSON.stringify(state, null, 2),
|
|
300
|
+
'-->',
|
|
301
|
+
'',
|
|
302
|
+
'# Execution',
|
|
303
|
+
`활성 Block: ${state.activeBlock ?? '없음'}`,
|
|
304
|
+
`활성 세션: ${state.activeSessions.join(', ') || '없음'}`,
|
|
305
|
+
`Write Boundary 점유: ${state.writeBoundaryOccupancy.join(', ') || '없음'}`,
|
|
306
|
+
`승인 대기: ${state.awaitingController.join(', ') || '없음'}`,
|
|
307
|
+
'',
|
|
308
|
+
'# Monitoring',
|
|
309
|
+
'포인터만 둔다 — 숫자·시각은 갱신자가 없으므로 적지 않는다.',
|
|
310
|
+
'Inbox 미처분: monitor/views/inbox.md',
|
|
311
|
+
'',
|
|
312
|
+
'# Controller Attention',
|
|
313
|
+
...(state.controllerAttention.length > 0 ? state.controllerAttention.map((a) => `- ${a}`) : ['- (없음)']),
|
|
314
|
+
];
|
|
315
|
+
return lines.join('\n') + '\n';
|
|
316
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MattermostPost, MattermostTransport } from './presentation.ts';
|
|
2
|
+
export type MattermostClientDeps = {
|
|
3
|
+
/** `https://mm.example.com` — 팀마다 자체 호스팅이 흔하다. */
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
/** 요청 통로 주입점. 테스트가 실제 네트워크를 치지 않기 위한 자리다. */
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
/** 환경에서만 읽는다. 값의 존재 여부만 밖에 알린다. */
|
|
11
|
+
export declare function discoverToken(env?: NodeJS.ProcessEnv): string | null;
|
|
12
|
+
export declare function discoverBaseUrl(env?: NodeJS.ProcessEnv): string | null;
|
|
13
|
+
export declare class MattermostClient implements MattermostTransport {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(deps: MattermostClientDeps);
|
|
16
|
+
post(payload: MattermostPost): Promise<{
|
|
17
|
+
ok: true;
|
|
18
|
+
id: string;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
error: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Mattermost transport — HTTP 한 겹 (B-13).
|
|
2
|
+
//
|
|
3
|
+
// 자격은 **환경에서만** 온다. Profile·lock·docs·log 어디에도 남기지 않는다 (C-11 §8).
|
|
4
|
+
// 이 파일이 토큰을 아는 것은 요청 헤더를 만드는 순간뿐이며, 실패 메시지에도 싣지 않는다 —
|
|
5
|
+
// 오류 로그가 자격 유출 경로가 되는 것은 흔한 사고다.
|
|
6
|
+
/** 환경에서만 읽는다. 값의 존재 여부만 밖에 알린다. */
|
|
7
|
+
export function discoverToken(env = process.env) {
|
|
8
|
+
return env.ASC_MATTERMOST_TOKEN ?? env.MATTERMOST_TOKEN ?? null;
|
|
9
|
+
}
|
|
10
|
+
export function discoverBaseUrl(env = process.env) {
|
|
11
|
+
return env.ASC_MATTERMOST_URL ?? env.MATTERMOST_URL ?? null;
|
|
12
|
+
}
|
|
13
|
+
export class MattermostClient {
|
|
14
|
+
#baseUrl;
|
|
15
|
+
#token;
|
|
16
|
+
#fetch;
|
|
17
|
+
#timeoutMs;
|
|
18
|
+
constructor(deps) {
|
|
19
|
+
this.#baseUrl = deps.baseUrl.replace(/\/+$/, '');
|
|
20
|
+
this.#token = deps.token;
|
|
21
|
+
this.#fetch = deps.fetchImpl ?? fetch;
|
|
22
|
+
this.#timeoutMs = deps.timeoutMs ?? 10_000;
|
|
23
|
+
}
|
|
24
|
+
async post(payload) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
// 매달린 요청이 회차를 통째로 잡아먹지 않게 한다
|
|
27
|
+
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
|
28
|
+
try {
|
|
29
|
+
const response = await this.#fetch(`${this.#baseUrl}/api/v4/posts`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
authorization: `Bearer ${this.#token}`,
|
|
33
|
+
'content-type': 'application/json',
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify(payload),
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
// 본문에 토큰이 되비쳐 오는 provider도 있다. 상태 코드까지만 옮긴다.
|
|
40
|
+
return { ok: false, error: `HTTP ${response.status}` };
|
|
41
|
+
}
|
|
42
|
+
const body = (await response.json());
|
|
43
|
+
return body.id ? { ok: true, id: body.id } : { ok: false, error: '응답에 post id가 없다' };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
47
|
+
// 자격이 메시지에 섞여 나가지 않게 한 번 더 거른다
|
|
48
|
+
return { ok: false, error: detail.replace(this.#token, '<redacted>') };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|