@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,247 @@
|
|
|
1
|
+
// Coordination Evidence — 물었다는 사실과, 전달됐다는 사실과, 답이 왔다는 사실을 나눈다.
|
|
2
|
+
//
|
|
3
|
+
// 반복된 실패는 이것이었다:
|
|
4
|
+
//
|
|
5
|
+
// dependency 가 있다
|
|
6
|
+
// ≠ 상대에게 전달됐다
|
|
7
|
+
// ≠ 상대가 응답했다
|
|
8
|
+
//
|
|
9
|
+
// 세 사실이 한 레코드로 뭉개지면 "Task 도 있고 무언가 오갔으니 됐다"가 성립한다.
|
|
10
|
+
// 그래서 뭉개지지 않게 **따로 적는다.**
|
|
11
|
+
//
|
|
12
|
+
// 새 subsystem 을 만들지 않는다. 기대는 이미 Bounded Query 가 지고 있고(C-04), 여기서
|
|
13
|
+
// 더하는 것은 C-10 과 같은 모양의 append-only 원장 둘뿐이다:
|
|
14
|
+
//
|
|
15
|
+
// ExecutionEvidence 누가 이 세션을 실제로 집었는가 (C-10, 이미 있음)
|
|
16
|
+
// CommunicationEvidence 그 기대가 실제로 밖에 게시됐는가 (여기)
|
|
17
|
+
// ResponseEvidence 그 게시물에 실제로 답이 왔는가 (여기)
|
|
18
|
+
//
|
|
19
|
+
// 상태를 저장하지 않는다. `UNPUBLISHED`·`WAITING_EXTERNAL`·`RESPONSE_RECEIVED` 는
|
|
20
|
+
// 전부 위 셋에서 **파생**한다 — 저장하면 그것이 곧 두 번째 정본이 되고, 증거와 어긋나는
|
|
21
|
+
// 순간 어느 쪽이 맞는지 정할 방법이 없어진다.
|
|
22
|
+
//
|
|
23
|
+
// **provider 이름을 모른다.** 아는 것은 adapter id 와 그 adapter 가 준 opaque identity 다
|
|
24
|
+
// (C-09 §6 과 같은 선). 어느 외부 시스템의 이름도 이 파일에 나오지 않으며, 그것은
|
|
25
|
+
// core/** 전체에 걸린 기존 규칙이기도 하다.
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { QUERY_ID } from "../model/ids.js";
|
|
28
|
+
/**
|
|
29
|
+
* 외부 시스템이 준 **안정적인** 신원 (F3).
|
|
30
|
+
*
|
|
31
|
+
* 사람이 보는 링크는 여기 있어도 되지만 그것이 정본은 아니다. 링크의 모양이 예상과
|
|
32
|
+
* 다르다는 이유로 생성이 실패했다고 읽고 하나 더 만든 사고가 실제로 있었다 — 그때
|
|
33
|
+
* 대조할 수 있는 다른 값이 계약에 없었다.
|
|
34
|
+
*/
|
|
35
|
+
export const RemoteIdentity = z.object({
|
|
36
|
+
/** 어느 adapter 가 이 사실을 만들었는가. provider 이름이 아니라 adapter id 다. */
|
|
37
|
+
adapter: z.string().min(1),
|
|
38
|
+
/** 그 adapter 의 어휘로 된 객체 종류. Core 는 이 문자열로 분기하지 않는다. */
|
|
39
|
+
objectType: z.string().min(1),
|
|
40
|
+
/** 그 시스템 안에서 이 객체를 다시 찾을 수 있는 값. **성공의 정본이다.** */
|
|
41
|
+
objectId: z.string().min(1),
|
|
42
|
+
/** 어느 자원 아래인가 (저장소·프로젝트 등). adapter 어휘 그대로. */
|
|
43
|
+
resource: z.string().min(1).optional(),
|
|
44
|
+
/**
|
|
45
|
+
* 사람이 열어 보는 주소. **판정에 쓰지 않는다** — 모양이 바뀌어도 같은 객체다.
|
|
46
|
+
*/
|
|
47
|
+
locator: z.string().min(1).optional(),
|
|
48
|
+
/** 이 시점의 판본. 있으면 응답 중복을 가리는 데 쓴다. */
|
|
49
|
+
revisionMarker: z.string().min(1).optional(),
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* 기대가 실제로 밖에 게시됐다는 증거.
|
|
53
|
+
*
|
|
54
|
+
* **이 레코드를 만드는 것이 게시가 아니다.** 게시는 adapter 가 하고, 여기 남는 것은
|
|
55
|
+
* 그것이 일어났다는 관측이다 — 그 구분이 사라지면 "적어 뒀으니 됐다"가 다시 성립한다.
|
|
56
|
+
*/
|
|
57
|
+
export const CommunicationEvidence = z.object({
|
|
58
|
+
evidenceId: z.string().min(1),
|
|
59
|
+
/** 어느 기대에 대한 것인가. 지금은 Bounded Query 가 기대의 정본이다 (C-04). */
|
|
60
|
+
queryId: z.string().regex(QUERY_ID),
|
|
61
|
+
/** 어느 binding 을 통해 나갔는가 (C-09). 역할 이름이며 provider 이름이 아니다. */
|
|
62
|
+
bindingRole: z.string().min(1).optional(),
|
|
63
|
+
identity: RemoteIdentity,
|
|
64
|
+
/** 누구에게 닿아야 하는가. 프로젝트 정책이 정하고 여기서는 그대로 보관한다. */
|
|
65
|
+
audience: z.array(z.string()).default([]),
|
|
66
|
+
publishedAt: z.string().min(1),
|
|
67
|
+
/** 이 사실을 우리가 언제 봤는가. 게시 시각과 다를 수 있다. */
|
|
68
|
+
observedAt: z.string().min(1),
|
|
69
|
+
/** 선언인가 관측인가 (C-10 의 같은 필드와 같은 뜻). */
|
|
70
|
+
evidenceSource: z.string().min(1),
|
|
71
|
+
});
|
|
72
|
+
/**
|
|
73
|
+
* 그 게시물에 외부 응답이 도착했다는 증거.
|
|
74
|
+
*
|
|
75
|
+
* **답의 의미를 판정하지 않는다.** 이것은 `QueryAnswer` 가 아니고, 자동으로 그것이
|
|
76
|
+
* 되지도 않는다 — 누가 무엇을 결정할 수 있는지는 C-04 의 권한 판정이고, 사람 승인은
|
|
77
|
+
* C-13 이다. 여기 있는 것은 "왔다"까지다.
|
|
78
|
+
*/
|
|
79
|
+
export const ResponseEvidence = z.object({
|
|
80
|
+
evidenceId: z.string().min(1),
|
|
81
|
+
/** 어느 게시물에 대한 응답인가. */
|
|
82
|
+
communicationId: z.string().min(1),
|
|
83
|
+
identity: RemoteIdentity,
|
|
84
|
+
/** 그 시스템이 말하는 응답자. 우리 역할 이름으로 번역하지 않는다. */
|
|
85
|
+
responder: z.string().min(1).optional(),
|
|
86
|
+
receivedAt: z.string().min(1),
|
|
87
|
+
observedAt: z.string().min(1),
|
|
88
|
+
evidenceSource: z.string().min(1),
|
|
89
|
+
});
|
|
90
|
+
/**
|
|
91
|
+
* 기대 하나의 지금 상태를 계산한다.
|
|
92
|
+
*
|
|
93
|
+
* 순서가 곧 규칙이다: 응답이 있으면 왔다, 없으면 기다리는지 아닌지, 게시가 없으면
|
|
94
|
+
* 아직 나가지 않았다. **없는 것을 있는 것으로 올리는 방향은 없다.**
|
|
95
|
+
*/
|
|
96
|
+
export function deriveCoordination(input) {
|
|
97
|
+
const communications = [...input.communications].sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
|
|
98
|
+
const responses = [...input.responses].sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
|
|
99
|
+
const state = communications.length === 0
|
|
100
|
+
? 'UNPUBLISHED'
|
|
101
|
+
: responses.length > 0
|
|
102
|
+
? 'RESPONSE_RECEIVED'
|
|
103
|
+
: input.expectsResponse
|
|
104
|
+
? 'WAITING_EXTERNAL'
|
|
105
|
+
: 'PUBLISHED';
|
|
106
|
+
return { queryId: input.queryId, state, expectsResponse: input.expectsResponse, communications, responses };
|
|
107
|
+
}
|
|
108
|
+
const communicationKey = (id) => `coord:pub:${id}`;
|
|
109
|
+
const responseKey = (id) => `coord:res:${id}`;
|
|
110
|
+
/**
|
|
111
|
+
* 게시물에 붙은 글 중 **응답으로 셀 것**을 고른다.
|
|
112
|
+
*
|
|
113
|
+
* 두 가지를 뺀다: 그 시스템이 스스로 남긴 자국(커밋 언급·배정 변경)과, 우리가 쓴 글.
|
|
114
|
+
* 둘 다 세면 아무도 답하지 않은 스레드가 답이 온 것으로 보인다 — 이 구조가 막으려는
|
|
115
|
+
* 바로 그 착각이다.
|
|
116
|
+
*
|
|
117
|
+
* **답의 의미는 판정하지 않는다.** 여기서 나오는 것은 "왔다"까지이고, 그것이 결정인지
|
|
118
|
+
* 승인인지는 C-04·C-13 의 몫이다.
|
|
119
|
+
*/
|
|
120
|
+
export function responsesFrom(communication, remarks, mine) {
|
|
121
|
+
return remarks
|
|
122
|
+
.filter((remark) => !remark.system && !mine.has(remark.author))
|
|
123
|
+
.map((remark) => ({
|
|
124
|
+
// 같은 글을 다시 봐도 같은 id 다 — append-only 원장이 두 번째를 거절한다.
|
|
125
|
+
evidenceId: `${communication.evidenceId}:${remark.id}`,
|
|
126
|
+
communicationId: communication.evidenceId,
|
|
127
|
+
identity: { ...communication.identity, revisionMarker: remark.at },
|
|
128
|
+
responder: remark.author,
|
|
129
|
+
receivedAt: remark.at,
|
|
130
|
+
evidenceSource: 'surface-remark',
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
export class CoordinationLedger {
|
|
134
|
+
#scope;
|
|
135
|
+
#now;
|
|
136
|
+
constructor(scope, now = () => new Date().toISOString()) {
|
|
137
|
+
this.#scope = scope;
|
|
138
|
+
this.#now = now;
|
|
139
|
+
}
|
|
140
|
+
async publishRecorded(input) {
|
|
141
|
+
const evidence = CommunicationEvidence.parse({ ...input, observedAt: input.observedAt ?? this.#now() });
|
|
142
|
+
const written = await this.#scope.setIfAbsent(communicationKey(evidence.evidenceId), JSON.stringify(evidence));
|
|
143
|
+
return written
|
|
144
|
+
? { ok: true, evidence }
|
|
145
|
+
: { ok: false, reason: 'ALREADY_EXISTS', detail: `${evidence.evidenceId} 는 이미 있다` };
|
|
146
|
+
}
|
|
147
|
+
async responseRecorded(input) {
|
|
148
|
+
const target = await this.#scope.get(communicationKey(input.communicationId));
|
|
149
|
+
if (!target) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
reason: 'COMMUNICATION_NOT_FOUND',
|
|
153
|
+
detail: `${input.communicationId} 를 찾지 못했다 — 게시된 적 없는 것에 응답을 붙이지 않는다`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const evidence = ResponseEvidence.parse({ ...input, observedAt: input.observedAt ?? this.#now() });
|
|
157
|
+
const written = await this.#scope.setIfAbsent(responseKey(evidence.evidenceId), JSON.stringify(evidence));
|
|
158
|
+
return written
|
|
159
|
+
? { ok: true, evidence }
|
|
160
|
+
: { ok: false, reason: 'ALREADY_EXISTS', detail: `${evidence.evidenceId} 는 이미 있다` };
|
|
161
|
+
}
|
|
162
|
+
async communications() {
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const key of await this.#scope.keys('coord:pub:')) {
|
|
165
|
+
const raw = await this.#scope.get(key);
|
|
166
|
+
if (raw)
|
|
167
|
+
out.push(CommunicationEvidence.parse(JSON.parse(raw)));
|
|
168
|
+
}
|
|
169
|
+
return out.sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
|
|
170
|
+
}
|
|
171
|
+
async responses() {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const key of await this.#scope.keys('coord:res:')) {
|
|
174
|
+
const raw = await this.#scope.get(key);
|
|
175
|
+
if (raw)
|
|
176
|
+
out.push(ResponseEvidence.parse(JSON.parse(raw)));
|
|
177
|
+
}
|
|
178
|
+
return out.sort((a, b) => a.evidenceId.localeCompare(b.evidenceId));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 이미 이 게시물이 있는가 — **원격 신원으로** 찾는다 (F3).
|
|
182
|
+
*
|
|
183
|
+
* 같은 것을 두 번 만들지 않기 위한 조회다. 제목이나 링크가 아니라 adapter 가 준
|
|
184
|
+
* 안정 id 로 본다.
|
|
185
|
+
*/
|
|
186
|
+
async findByIdentity(identity) {
|
|
187
|
+
for (const evidence of await this.communications()) {
|
|
188
|
+
const seen = evidence.identity;
|
|
189
|
+
if (seen.adapter === identity.adapter && seen.objectType === identity.objectType && seen.objectId === identity.objectId) {
|
|
190
|
+
return evidence;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
/** 기대 하나에 달린 증거 전부. */
|
|
196
|
+
async forQuery(queryId) {
|
|
197
|
+
const communications = (await this.communications()).filter((evidence) => evidence.queryId === queryId);
|
|
198
|
+
const ids = new Set(communications.map((evidence) => evidence.evidenceId));
|
|
199
|
+
const responses = (await this.responses()).filter((evidence) => ids.has(evidence.communicationId));
|
|
200
|
+
return { communications, responses };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 기대 목록과 증거를 합쳐 지금 상태를 낸다.
|
|
205
|
+
*
|
|
206
|
+
* 기대는 이 모듈이 만들지 않는다 — Bounded Query 가 정본이고, 여기서는 그것을 받아
|
|
207
|
+
* 증거와 맞춘다. 기대가 없으면 조율도 없다.
|
|
208
|
+
*/
|
|
209
|
+
export async function viewCoordination(ledger, expectations) {
|
|
210
|
+
const views = [];
|
|
211
|
+
for (const expectation of expectations) {
|
|
212
|
+
const { communications, responses } = await ledger.forQuery(expectation.id);
|
|
213
|
+
views.push(deriveCoordination({
|
|
214
|
+
queryId: expectation.id,
|
|
215
|
+
expectsResponse: expectation.expectsResponse,
|
|
216
|
+
communications,
|
|
217
|
+
responses,
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
return views.sort((a, b) => a.queryId.localeCompare(b.queryId));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* 사람이 읽는 줄. **없는 것을 0 으로 그리지 않는다** — 기대가 없으면 그렇게 말한다.
|
|
224
|
+
*/
|
|
225
|
+
export function coordinationLines(views) {
|
|
226
|
+
if (views.length === 0)
|
|
227
|
+
return ['No external expectation is recorded here.'];
|
|
228
|
+
const lines = [];
|
|
229
|
+
const counts = {
|
|
230
|
+
UNPUBLISHED: 0,
|
|
231
|
+
WAITING_EXTERNAL: 0,
|
|
232
|
+
RESPONSE_RECEIVED: 0,
|
|
233
|
+
PUBLISHED: 0,
|
|
234
|
+
};
|
|
235
|
+
for (const view of views)
|
|
236
|
+
counts[view.state] += 1;
|
|
237
|
+
lines.push(`Coordination: ${views.length} expectation${views.length === 1 ? '' : 's'} — ` +
|
|
238
|
+
`unpublished ${counts.UNPUBLISHED} · waiting ${counts.WAITING_EXTERNAL} · ` +
|
|
239
|
+
`answered ${counts.RESPONSE_RECEIVED} · published ${counts.PUBLISHED}`);
|
|
240
|
+
for (const view of views) {
|
|
241
|
+
const where = view.communications[0]?.identity;
|
|
242
|
+
// 링크는 사람이 열어 보는 값이다. 없으면 안정 신원을 보인다 — 판정의 정본은 그쪽이다.
|
|
243
|
+
const at = where ? ` @ ${where.locator ?? `${where.adapter}:${where.objectType}:${where.objectId}`}` : '';
|
|
244
|
+
lines.push(` ${view.queryId} ${view.state}${at}`);
|
|
245
|
+
}
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { StateStore } from '../../ports/state-store.ts';
|
|
|
2
2
|
import type { HealthAlert } from '../monitor/health-alerts.ts';
|
|
3
3
|
import type { EscalationRecord } from './escalation.ts';
|
|
4
4
|
import type { DecisionSummary } from '../view/decision-view.ts';
|
|
5
|
+
import type { CoordinationView } from './coordination.ts';
|
|
5
6
|
export type FrontState = {
|
|
6
7
|
/** 지금 돌고 있는 것. */
|
|
7
8
|
active: {
|
|
@@ -57,6 +58,13 @@ export type FrontState = {
|
|
|
57
58
|
workspaceId: string;
|
|
58
59
|
locator: string;
|
|
59
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* 밖에 물어 둔 것들의 지금 상태.
|
|
63
|
+
*
|
|
64
|
+
* **저장된 값이 아니다** — 기대와 증거에서 파생해 받은 것을 그대로 보인다. 이것이
|
|
65
|
+
* 화면에 없으면 "안에서 할 일이 없다"가 "끝났다"로 읽힌다.
|
|
66
|
+
*/
|
|
67
|
+
coordination?: readonly CoordinationView[];
|
|
60
68
|
};
|
|
61
69
|
export type RestoreInput = {
|
|
62
70
|
store: StateStore;
|
|
@@ -68,6 +76,8 @@ export type RestoreInput = {
|
|
|
68
76
|
workspaceId: string;
|
|
69
77
|
locator: string;
|
|
70
78
|
};
|
|
79
|
+
/** 파생된 조율 상태. 없으면 이 축을 그리지 않는다 — 모르는 것을 "없음"으로 그리지 않는다. */
|
|
80
|
+
coordination?: readonly CoordinationView[];
|
|
71
81
|
/**
|
|
72
82
|
* 세션별 소유권 조회. **구조로만 받는다** — Core가 Host adapter를 알면 안 된다.
|
|
73
83
|
* 없으면 이 축은 그리지 않는다(모르는 것을 "없음"으로 그리지 않는다).
|
|
@@ -85,6 +95,54 @@ export type RestoreInput = {
|
|
|
85
95
|
* 새로 계산하지 않는다. 여기서 파생을 만들면 그 파생이 곧 두 번째 정본이 된다.
|
|
86
96
|
*/
|
|
87
97
|
export declare function restoreFront(input: RestoreInput): Promise<FrontState>;
|
|
98
|
+
/**
|
|
99
|
+
* 새 Front Session이 열렸을 때의 판정 (C-12 §4).
|
|
100
|
+
*
|
|
101
|
+
* **Host를 모른다.** Claude Code든 다른 무엇이든 "여기서 세션이 열렸다"는 사실 하나를
|
|
102
|
+
* 받고, 붙을 수 있는지와 무엇이 걸려 있는지를 답한다. Host adapter는 이 답을 자기
|
|
103
|
+
* 호스트의 형식으로 옮기기만 한다 — Core에 `if (host === 'claude')` 가 생기면 그 순간
|
|
104
|
+
* 두 번째 호스트는 Core를 고쳐야 들어온다.
|
|
105
|
+
*
|
|
106
|
+
* 세 갈래를 **구분한다**:
|
|
107
|
+
*
|
|
108
|
+
* NOT_ASC 이 경로는 ASC 소관이 아니다. 조용히 지나간다 — 남의 도구를 방해하지
|
|
109
|
+
* 않는다 (C-11 불변식 ⑪).
|
|
110
|
+
* UNAVAILABLE 붙어야 하는데 못 붙었다. **빈 화면을 주지 않고 왜인지 말한다**
|
|
111
|
+
* (C-12 불변식 ⑰).
|
|
112
|
+
* BOUND 붙었다. 지금 무엇이 걸려 있는지가 함께 온다.
|
|
113
|
+
*/
|
|
114
|
+
export type FrontOpening = {
|
|
115
|
+
kind: 'NOT_ASC';
|
|
116
|
+
} | {
|
|
117
|
+
kind: 'UNAVAILABLE';
|
|
118
|
+
detail: string;
|
|
119
|
+
} | {
|
|
120
|
+
kind: 'BOUND';
|
|
121
|
+
state: FrontState;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* 세션이 열렸다 — 붙을 수 있는가.
|
|
125
|
+
*
|
|
126
|
+
* **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
|
|
127
|
+
* 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
|
|
128
|
+
* demonstrate that setup worked" 와 같은 선).
|
|
129
|
+
*/
|
|
130
|
+
export declare function openFront(input: {
|
|
131
|
+
/** 이 경로가 어느 workspace인가. `null` 이면 ASC 소관이 아니다. */
|
|
132
|
+
workspace: {
|
|
133
|
+
workspaceId: string;
|
|
134
|
+
locator: string;
|
|
135
|
+
} | null;
|
|
136
|
+
/** 상태를 읽는다. 던지면 UNAVAILABLE 로 접힌다 — 못 읽은 것을 "없음"으로 그리지 않는다. */
|
|
137
|
+
restore: () => Promise<FrontState>;
|
|
138
|
+
}): Promise<FrontOpening>;
|
|
139
|
+
/**
|
|
140
|
+
* Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
|
|
141
|
+
*
|
|
142
|
+
* `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
|
|
143
|
+
* 아무것도 띄우지 않는 것이 맞다.
|
|
144
|
+
*/
|
|
145
|
+
export declare function frontOpeningLines(opening: FrontOpening): string[];
|
|
88
146
|
/**
|
|
89
147
|
* 사람이 읽는 복원 화면.
|
|
90
148
|
*
|
|
@@ -61,8 +61,43 @@ export async function restoreFront(input) {
|
|
|
61
61
|
runnable: [...record.stillRunnableNodes],
|
|
62
62
|
})),
|
|
63
63
|
...(input.workspace ? { workspace: input.workspace } : {}),
|
|
64
|
+
...(input.coordination ? { coordination: input.coordination } : {}),
|
|
64
65
|
};
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* 세션이 열렸다 — 붙을 수 있는가.
|
|
69
|
+
*
|
|
70
|
+
* **읽기만 한다.** 세션을 만들지 않는다: 실제 작업 요청이 없는데 Session Contract를
|
|
71
|
+
* 만들어 내는 것은 없던 사실을 제조하는 일이다 (AGENTS.md "Do not create a session to
|
|
72
|
+
* demonstrate that setup worked" 와 같은 선).
|
|
73
|
+
*/
|
|
74
|
+
export async function openFront(input) {
|
|
75
|
+
if (!input.workspace)
|
|
76
|
+
return { kind: 'NOT_ASC' };
|
|
77
|
+
try {
|
|
78
|
+
return { kind: 'BOUND', state: await input.restore() };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return { kind: 'UNAVAILABLE', detail: error instanceof Error ? error.message : String(error) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Host가 사람에게 보여 줄 줄. **Host별 포장은 여기 없다** — 문자열 목록까지가 Core다.
|
|
86
|
+
*
|
|
87
|
+
* `NOT_ASC` 는 빈 목록이다. 할 말이 없는 것과 "아무 일도 없다"는 다르고, 전자는
|
|
88
|
+
* 아무것도 띄우지 않는 것이 맞다.
|
|
89
|
+
*/
|
|
90
|
+
export function frontOpeningLines(opening) {
|
|
91
|
+
switch (opening.kind) {
|
|
92
|
+
case 'NOT_ASC':
|
|
93
|
+
return [];
|
|
94
|
+
case 'UNAVAILABLE':
|
|
95
|
+
// 조용히 빈 화면을 주지 않는다 (C-12 불변식 ⑰)
|
|
96
|
+
return [`ASC is attached here but its state could not be read — ${opening.detail}`];
|
|
97
|
+
case 'BOUND':
|
|
98
|
+
return renderFront(opening.state);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
66
101
|
/**
|
|
67
102
|
* 사람이 읽는 복원 화면.
|
|
68
103
|
*
|
|
@@ -129,12 +164,26 @@ export function renderFront(state) {
|
|
|
129
164
|
lines.push(` … and ${state.awaitingController.length - 5} more`);
|
|
130
165
|
}
|
|
131
166
|
}
|
|
167
|
+
// 밖에 걸린 것을 안에서 할 일과 나눠 든다. 나가지 않은 기대는 **아무도 기다리지
|
|
168
|
+
// 않는다** — 그것이 가장 조용한 실패다.
|
|
169
|
+
const outward = (state.coordination ?? []).filter((view) => view.state !== 'PUBLISHED');
|
|
170
|
+
if (outward.length > 0) {
|
|
171
|
+
const unpublished = outward.filter((view) => view.state === 'UNPUBLISHED');
|
|
172
|
+
const waiting = outward.filter((view) => view.state === 'WAITING_EXTERNAL');
|
|
173
|
+
const answered = outward.filter((view) => view.state === 'RESPONSE_RECEIVED');
|
|
174
|
+
lines.push(`Asked outside (${outward.length}): not sent ${unpublished.length} · waiting ${waiting.length} · answered ${answered.length}`);
|
|
175
|
+
// 안 나간 것을 먼저 든다 — 기다리는 것보다 나쁜 상태다
|
|
176
|
+
for (const view of [...unpublished, ...answered, ...waiting]) {
|
|
177
|
+
lines.push(` ${view.queryId} ${view.state}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
132
180
|
if (state.pendingDecisions.length === 0 &&
|
|
133
181
|
state.active.length === 0 &&
|
|
134
182
|
state.escalations.length === 0 &&
|
|
135
183
|
state.unclaimed.length === 0 &&
|
|
136
184
|
state.awaitingCollect.length === 0 &&
|
|
137
|
-
state.awaitingController.length === 0
|
|
185
|
+
state.awaitingController.length === 0 &&
|
|
186
|
+
outward.length === 0) {
|
|
138
187
|
// 비어 있는 것과 못 보는 것을 구분한다 (C-12 불변식 ⑫과 같은 태도)
|
|
139
188
|
lines.push(state.health.length > 0
|
|
140
189
|
? 'Nothing is pending — but read the monitoring state above first.'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CoordinationSurfacePort, PublicPayload, SurfaceCandidate } from '../../ports/coordination-surface.ts';
|
|
2
|
+
import type { CoordinationLedger, CommunicationEvidence, RemoteIdentity } from './coordination.ts';
|
|
3
|
+
/**
|
|
4
|
+
* 게시 의도. **내부와 공개를 처음부터 다른 필드로 든다** (F4).
|
|
5
|
+
*
|
|
6
|
+
* 한 문자열에 담았다가 나중에 가르는 경로는 여기 없다. `internal` 은 이 타입 밖으로
|
|
7
|
+
* 나가지 않으며, Port 가 받는 것은 `publicPayload` 뿐이다.
|
|
8
|
+
*/
|
|
9
|
+
export type PublishIntent = {
|
|
10
|
+
/** 어느 기대에 대한 게시인가. */
|
|
11
|
+
queryId: string;
|
|
12
|
+
/** 밖으로 나가는 내용 전부. */
|
|
13
|
+
publicPayload: PublicPayload;
|
|
14
|
+
/** 상관 관계·근거 출처·라우팅·사적 메모. **경계를 넘지 않는다.** */
|
|
15
|
+
internal?: Record<string, unknown>;
|
|
16
|
+
/** 누구에게 닿아야 하는가. 프로젝트 정책이 정한다. */
|
|
17
|
+
audience?: readonly string[];
|
|
18
|
+
/** 이미 아는 게시물. 있으면 찾기의 첫 근거가 된다. */
|
|
19
|
+
known?: readonly Pick<RemoteIdentity, 'objectType' | 'objectId'>[];
|
|
20
|
+
/** 관련 작업 항목. adapter 어휘 그대로. */
|
|
21
|
+
workReference?: string;
|
|
22
|
+
};
|
|
23
|
+
export type PublishDecision = {
|
|
24
|
+
verdict: 'ATTACH_EXISTING';
|
|
25
|
+
candidate: SurfaceCandidate;
|
|
26
|
+
why: string;
|
|
27
|
+
} | {
|
|
28
|
+
verdict: 'CREATE_NEW';
|
|
29
|
+
why: string;
|
|
30
|
+
} | {
|
|
31
|
+
verdict: 'AMBIGUOUS';
|
|
32
|
+
candidates: SurfaceCandidate[];
|
|
33
|
+
why: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* 찾은 것들을 보고 무엇을 할지 정한다. **밖을 치지 않는다** — 판정만 한다.
|
|
37
|
+
*
|
|
38
|
+
* 강한 근거(이미 아는 신원 · 우리가 심은 상관 관계)가 정확히 하나면 그것에 붙는다.
|
|
39
|
+
* 강한 근거가 여럿이면 고르지 않는다. 약한 근거뿐이면 그것으로 붙지 않고, 그렇다고
|
|
40
|
+
* 새로 만들지도 않는다 — 같은 것이 이미 있을 수 있기 때문이다.
|
|
41
|
+
*/
|
|
42
|
+
export declare function decidePublish(candidates: readonly SurfaceCandidate[]): PublishDecision;
|
|
43
|
+
export type PublishOutcome =
|
|
44
|
+
/** 새로 만들었고, 되읽어 확인했다. */
|
|
45
|
+
{
|
|
46
|
+
ok: true;
|
|
47
|
+
action: 'CREATED';
|
|
48
|
+
identity: RemoteIdentity;
|
|
49
|
+
evidence: Omit<CommunicationEvidence, 'evidenceId' | 'observedAt'>;
|
|
50
|
+
}
|
|
51
|
+
/** 이미 있던 것에 붙였다. 아무것도 만들지 않았다. */
|
|
52
|
+
| {
|
|
53
|
+
ok: true;
|
|
54
|
+
action: 'ATTACHED';
|
|
55
|
+
identity: RemoteIdentity;
|
|
56
|
+
evidence: Omit<CommunicationEvidence, 'evidenceId' | 'observedAt'>;
|
|
57
|
+
}
|
|
58
|
+
/** 사람이 정해야 한다. **만들지 않았다.** */
|
|
59
|
+
| {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason: 'AMBIGUOUS';
|
|
62
|
+
candidates: SurfaceCandidate[];
|
|
63
|
+
detail: string;
|
|
64
|
+
}
|
|
65
|
+
/** 찾지 못했다 — 없는 것과 다르다. 이 상태에서 만들지 않는다. */
|
|
66
|
+
| {
|
|
67
|
+
ok: false;
|
|
68
|
+
reason: 'DISCOVERY_FAILED';
|
|
69
|
+
detail: string;
|
|
70
|
+
}
|
|
71
|
+
/** 만들었다는데 되읽히지 않는다. 다시 만들지 않는다. */
|
|
72
|
+
| {
|
|
73
|
+
ok: false;
|
|
74
|
+
reason: 'NOT_VERIFIED';
|
|
75
|
+
identity: RemoteIdentity;
|
|
76
|
+
detail: string;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
reason: 'CREATE_FAILED';
|
|
80
|
+
detail: string;
|
|
81
|
+
};
|
|
82
|
+
export type PublishDeps = {
|
|
83
|
+
surface: CoordinationSurfacePort;
|
|
84
|
+
/** 이 binding 이 어떤 역할로 쓰이는가 (C-09). provider 이름이 아니다. */
|
|
85
|
+
bindingRole?: string;
|
|
86
|
+
now?: () => string;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* 한 번 게시한다.
|
|
90
|
+
*
|
|
91
|
+
* **되읽기가 성공의 정본이다.** 만들기가 예외 없이 끝났다는 것만으로 끝내지 않는다 —
|
|
92
|
+
* 그 반대(만들어졌는데 응답을 잃음)가 실제로 일어났고, 그때 다시 만들면 중복이 된다.
|
|
93
|
+
* 되읽히지 않으면 실패로 적되 **다시 만들지 않는다**: 다음 회차가 찾기부터 다시 한다.
|
|
94
|
+
*/
|
|
95
|
+
export declare function publishOnce(intent: PublishIntent, deps: PublishDeps): Promise<PublishOutcome>;
|
|
96
|
+
/**
|
|
97
|
+
* 게시 사실을 원장에 남긴다.
|
|
98
|
+
*
|
|
99
|
+
* `evidenceId` 를 **신원에서 만든다** — 같은 게시물을 다시 봐도 같은 id 가 나오고,
|
|
100
|
+
* 원장은 append-only 이므로 두 번째 기록은 조용히 거절된다 (F3). 무작위 id 를 쓰면
|
|
101
|
+
* 재시도할 때마다 같은 게시가 새 사실로 쌓인다.
|
|
102
|
+
*/
|
|
103
|
+
export declare function recordPublication(ledger: CoordinationLedger, outcome: Extract<PublishOutcome, {
|
|
104
|
+
ok: true;
|
|
105
|
+
}>): Promise<Awaited<ReturnType<CoordinationLedger['publishRecorded']>>>;
|
|
106
|
+
/** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
|
|
107
|
+
export declare function publishLine(outcome: PublishOutcome): string;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Publish — 밖에 하나를 만들되, 두 번 만들지 않는다.
|
|
2
|
+
//
|
|
3
|
+
// 실 프로젝트에서 이런 일이 있었다:
|
|
4
|
+
//
|
|
5
|
+
// 생성 요청 → 원격에 실제로 생성됨 → 돌아온 주소 모양이 예상과 다름
|
|
6
|
+
// → 실패로 판정 → 다시 생성 → 같은 것이 둘
|
|
7
|
+
//
|
|
8
|
+
// 원인은 재시도가 아니라 **성공의 정본이 주소였다**는 것이다. 주소는 사람이 여는 값이고
|
|
9
|
+
// 시스템마다 모양이 다르며 바뀌기도 한다. 그것으로 존재를 판정하면 존재하지 않는 실패가
|
|
10
|
+
// 만들어진다.
|
|
11
|
+
//
|
|
12
|
+
// 그래서 순서를 고정한다:
|
|
13
|
+
//
|
|
14
|
+
// 찾는다 → 정한다 → (필요하면) 만든다 → 되읽는다 → 증거를 남긴다
|
|
15
|
+
//
|
|
16
|
+
// 그리고 세 판정 중 하나로만 끝난다:
|
|
17
|
+
//
|
|
18
|
+
// ATTACH_EXISTING 이미 있다. 만들지 않는다
|
|
19
|
+
// CREATE_NEW 없다. 만든다
|
|
20
|
+
// AMBIGUOUS 모르겠다. **만들지 않는다**
|
|
21
|
+
//
|
|
22
|
+
// 모호할 때 만드는 것이 중복을 만든다. 제목이 비슷하다는 것은 근거가 아니다 — 안정적인
|
|
23
|
+
// 관계 근거가 없으면 모르는 것이 맞고, 그 판단은 사람이 한다.
|
|
24
|
+
/**
|
|
25
|
+
* 찾은 것들을 보고 무엇을 할지 정한다. **밖을 치지 않는다** — 판정만 한다.
|
|
26
|
+
*
|
|
27
|
+
* 강한 근거(이미 아는 신원 · 우리가 심은 상관 관계)가 정확히 하나면 그것에 붙는다.
|
|
28
|
+
* 강한 근거가 여럿이면 고르지 않는다. 약한 근거뿐이면 그것으로 붙지 않고, 그렇다고
|
|
29
|
+
* 새로 만들지도 않는다 — 같은 것이 이미 있을 수 있기 때문이다.
|
|
30
|
+
*/
|
|
31
|
+
export function decidePublish(candidates) {
|
|
32
|
+
const strong = candidates.filter((candidate) => candidate.matchedBy === 'known-identity' || candidate.matchedBy === 'correlation');
|
|
33
|
+
if (strong.length === 1) {
|
|
34
|
+
return {
|
|
35
|
+
verdict: 'ATTACH_EXISTING',
|
|
36
|
+
candidate: strong[0],
|
|
37
|
+
why: `이미 있다 (${strong[0].matchedBy})`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (strong.length > 1) {
|
|
41
|
+
return {
|
|
42
|
+
verdict: 'AMBIGUOUS',
|
|
43
|
+
candidates: strong,
|
|
44
|
+
why: `강한 근거를 가진 후보가 ${strong.length}개다 — 고르지 않는다`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const weak = candidates.filter((candidate) => candidate.matchedBy === 'work-reference' || candidate.matchedBy === 'weak');
|
|
48
|
+
if (weak.length > 0) {
|
|
49
|
+
// 제목이나 작업 항목이 겹친다는 것은 같은 조율이라는 뜻이 아니다. 붙이지도, 만들지도
|
|
50
|
+
// 않는다 — 여기서 만들면 중복이 나고, 붙이면 남의 스레드에 끼어든다.
|
|
51
|
+
return {
|
|
52
|
+
verdict: 'AMBIGUOUS',
|
|
53
|
+
candidates: weak,
|
|
54
|
+
why: `약한 근거만 있다 (${weak.map((candidate) => candidate.matchedBy).join(', ')}) — 사람이 정한다`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { verdict: 'CREATE_NEW', why: '후보 없음' };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 한 번 게시한다.
|
|
61
|
+
*
|
|
62
|
+
* **되읽기가 성공의 정본이다.** 만들기가 예외 없이 끝났다는 것만으로 끝내지 않는다 —
|
|
63
|
+
* 그 반대(만들어졌는데 응답을 잃음)가 실제로 일어났고, 그때 다시 만들면 중복이 된다.
|
|
64
|
+
* 되읽히지 않으면 실패로 적되 **다시 만들지 않는다**: 다음 회차가 찾기부터 다시 한다.
|
|
65
|
+
*/
|
|
66
|
+
export async function publishOnce(intent, deps) {
|
|
67
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
68
|
+
const query = {
|
|
69
|
+
correlation: intent.queryId,
|
|
70
|
+
...(intent.workReference ? { workReference: intent.workReference } : {}),
|
|
71
|
+
...(intent.known ? { known: intent.known } : {}),
|
|
72
|
+
};
|
|
73
|
+
let candidates;
|
|
74
|
+
try {
|
|
75
|
+
candidates = await deps.surface.find(query);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// 못 찾은 것을 "없다"로 읽으면 그 자리에서 중복이 난다.
|
|
79
|
+
return { ok: false, reason: 'DISCOVERY_FAILED', detail: String(error) };
|
|
80
|
+
}
|
|
81
|
+
const decision = decidePublish(candidates);
|
|
82
|
+
if (decision.verdict === 'AMBIGUOUS') {
|
|
83
|
+
return { ok: false, reason: 'AMBIGUOUS', candidates: decision.candidates, detail: decision.why };
|
|
84
|
+
}
|
|
85
|
+
const evidenceFor = (identity, source) => ({
|
|
86
|
+
queryId: intent.queryId,
|
|
87
|
+
...(deps.bindingRole ? { bindingRole: deps.bindingRole } : {}),
|
|
88
|
+
identity,
|
|
89
|
+
audience: [...(intent.audience ?? [])],
|
|
90
|
+
publishedAt: now(),
|
|
91
|
+
evidenceSource: source,
|
|
92
|
+
});
|
|
93
|
+
if (decision.verdict === 'ATTACH_EXISTING') {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
action: 'ATTACHED',
|
|
97
|
+
identity: decision.candidate.identity,
|
|
98
|
+
evidence: evidenceFor(decision.candidate.identity, 'surface-discovered'),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
let created;
|
|
102
|
+
try {
|
|
103
|
+
// **공개될 것만 넘어간다.** `intent.internal` 은 이 호출에 닿지 않는다 (F4).
|
|
104
|
+
created = await deps.surface.create(intent.publicPayload, query);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return { ok: false, reason: 'CREATE_FAILED', detail: String(error) };
|
|
108
|
+
}
|
|
109
|
+
const snapshot = await deps.surface.read(created).catch(() => null);
|
|
110
|
+
if (!snapshot) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: 'NOT_VERIFIED',
|
|
114
|
+
identity: created,
|
|
115
|
+
detail: '만들었다고 했는데 되읽히지 않는다 — 다시 만들지 않는다. 다음 회차가 찾기부터 다시 한다.',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
action: 'CREATED',
|
|
121
|
+
identity: snapshot.identity,
|
|
122
|
+
evidence: evidenceFor(snapshot.identity, 'surface-read-back'),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 게시 사실을 원장에 남긴다.
|
|
127
|
+
*
|
|
128
|
+
* `evidenceId` 를 **신원에서 만든다** — 같은 게시물을 다시 봐도 같은 id 가 나오고,
|
|
129
|
+
* 원장은 append-only 이므로 두 번째 기록은 조용히 거절된다 (F3). 무작위 id 를 쓰면
|
|
130
|
+
* 재시도할 때마다 같은 게시가 새 사실로 쌓인다.
|
|
131
|
+
*/
|
|
132
|
+
export async function recordPublication(ledger, outcome) {
|
|
133
|
+
const { identity } = outcome;
|
|
134
|
+
const evidenceId = `${outcome.evidence.queryId}:${identity.adapter}:${identity.objectType}:${identity.objectId}`;
|
|
135
|
+
return ledger.publishRecorded({ ...outcome.evidence, evidenceId });
|
|
136
|
+
}
|
|
137
|
+
/** 사람이 읽는 한 줄. 무엇을 했는지·무엇이 남았는지가 여기 있어야 한다. */
|
|
138
|
+
export function publishLine(outcome) {
|
|
139
|
+
if (outcome.ok) {
|
|
140
|
+
const at = outcome.identity.locator ?? `${outcome.identity.objectType}:${outcome.identity.objectId}`;
|
|
141
|
+
return outcome.action === 'CREATED' ? `published — ${at}` : `already there, attached — ${at}`;
|
|
142
|
+
}
|
|
143
|
+
switch (outcome.reason) {
|
|
144
|
+
case 'AMBIGUOUS':
|
|
145
|
+
return `not published — ${outcome.detail}. ${outcome.candidates.length} candidate(s); a person picks one.`;
|
|
146
|
+
case 'DISCOVERY_FAILED':
|
|
147
|
+
return `not published — could not look for an existing one (${outcome.detail}). Nothing was created.`;
|
|
148
|
+
case 'NOT_VERIFIED':
|
|
149
|
+
return `created but not verified — ${outcome.detail}`;
|
|
150
|
+
case 'CREATE_FAILED':
|
|
151
|
+
return `not published — ${outcome.detail}`;
|
|
152
|
+
}
|
|
153
|
+
}
|