@asc-agent/runtime 0.4.0 → 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/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/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.js +18 -1
- package/dist/adapters/service/systemd-user.js +4 -1
- package/dist/adapters/webhook/ingress.d.ts +2 -2
- package/dist/cli/asc.js +244 -6
- package/dist/composition/runtime.d.ts +3 -0
- package/dist/composition/runtime.js +6 -1
- 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 +58 -1
- package/dist/core/distribution/persistent-runtime.d.ts +9 -0
- package/dist/core/distribution/release.d.ts +3 -3
- package/dist/core/distribution/release.js +1 -1
- package/dist/core/runtime/coordination.d.ts +312 -0
- package/dist/core/runtime/coordination.js +247 -0
- package/dist/core/runtime/front.d.ts +10 -0
- package/dist/core/runtime/front.js +16 -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 +22 -0
- package/dist/core/runtime/workspaces.js +18 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ proceed-by-default, escalation, audit, the external-write guard, host integratio
|
|
|
7
7
|
here.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install -g @asc-agent/runtime@0.
|
|
10
|
+
npm install -g @asc-agent/runtime@0.5.0
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CoordinationSurfacePort, PublicPayload, SurfaceCandidate, SurfaceQuery, SurfaceSnapshot } from '../../ports/coordination-surface.ts';
|
|
2
|
+
import type { RemoteIdentity } from '../../core/runtime/coordination.ts';
|
|
3
|
+
type Stored = {
|
|
4
|
+
objectId: string;
|
|
5
|
+
payload: PublicPayload;
|
|
6
|
+
correlation: string;
|
|
7
|
+
workReference?: string;
|
|
8
|
+
closed?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type FixtureSurfaceDeps = {
|
|
11
|
+
/** 이 횟수만큼은 만들고 나서 응답을 잃는다. 객체는 남는다 — 그것이 이 사고의 요점이다. */
|
|
12
|
+
loseNextResponses?: number;
|
|
13
|
+
/** 주소를 어떻게 짓는가. 판정이 주소에 기대고 있으면 이 값을 바꿔 보면 드러난다. */
|
|
14
|
+
locatorShape?: (objectId: string) => string;
|
|
15
|
+
/** 찾기가 실패하는가. 못 찾은 것과 없는 것은 다른 사실이다. */
|
|
16
|
+
findFails?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare class FixtureSurfaceAdapter implements CoordinationSurfacePort {
|
|
19
|
+
#private;
|
|
20
|
+
readonly id = "fixture-surface";
|
|
21
|
+
readonly objects: Stored[];
|
|
22
|
+
/** 실제로 만들기가 몇 번 일어났는가. 중복 생성 0 을 세는 자리다. */
|
|
23
|
+
createCalls: number;
|
|
24
|
+
constructor(deps?: FixtureSurfaceDeps);
|
|
25
|
+
find(query: SurfaceQuery): Promise<SurfaceCandidate[]>;
|
|
26
|
+
create(payload: PublicPayload, query: SurfaceQuery): Promise<RemoteIdentity>;
|
|
27
|
+
read(identity: Pick<RemoteIdentity, 'objectType' | 'objectId'>): Promise<SurfaceSnapshot | null>;
|
|
28
|
+
/** 밖에서 일어난 일을 흉내 낸다 — 시험이 게시물을 닫거나 남의 것을 심을 때 쓴다. */
|
|
29
|
+
seed(stored: Omit<Stored, 'objectId'> & {
|
|
30
|
+
objectId?: string;
|
|
31
|
+
}): RemoteIdentity;
|
|
32
|
+
}
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Fixture Coordination Surface — 조율 표면 계약을 provider 없이 돌린다 (R7).
|
|
2
|
+
//
|
|
3
|
+
// 왜 있는가: 조율이 특정 회사 도구의 성질이 아니라는 것은, 그 도구가 아닌 것으로도
|
|
4
|
+
// 같은 계약이 성립할 때만 증명된다. 여기 있는 것은 메모리 위의 게시판 하나이며,
|
|
5
|
+
// Core 는 이것과 실제 adapter 를 구분하지 못한다 — 구분하면 그것이 결함이다.
|
|
6
|
+
//
|
|
7
|
+
// 실 사고를 흉내 낼 수 있어야 쓸모가 있다. 그래서 두 개의 손잡이가 있다:
|
|
8
|
+
// `loseNextResponses` — 만들어지긴 하는데 응답을 잃는다 (중복 생성의 실제 원인)
|
|
9
|
+
// `locatorShape` — 주소 모양이 바뀐다 (주소로 판정하면 여기서 깨진다)
|
|
10
|
+
export class FixtureSurfaceAdapter {
|
|
11
|
+
id = 'fixture-surface';
|
|
12
|
+
objects = [];
|
|
13
|
+
/** 실제로 만들기가 몇 번 일어났는가. 중복 생성 0 을 세는 자리다. */
|
|
14
|
+
createCalls = 0;
|
|
15
|
+
#lose;
|
|
16
|
+
#locator;
|
|
17
|
+
#findFails;
|
|
18
|
+
constructor(deps = {}) {
|
|
19
|
+
this.#lose = deps.loseNextResponses ?? 0;
|
|
20
|
+
this.#locator = deps.locatorShape ?? ((objectId) => `fixture://board/${objectId}`);
|
|
21
|
+
this.#findFails = deps.findFails ?? false;
|
|
22
|
+
}
|
|
23
|
+
#identity(stored) {
|
|
24
|
+
return {
|
|
25
|
+
adapter: this.id,
|
|
26
|
+
objectType: 'thread',
|
|
27
|
+
objectId: stored.objectId,
|
|
28
|
+
resource: 'board',
|
|
29
|
+
locator: this.#locator(stored.objectId),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async find(query) {
|
|
33
|
+
if (this.#findFails)
|
|
34
|
+
throw new Error('surface unreachable');
|
|
35
|
+
const out = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const push = (stored, matchedBy) => {
|
|
38
|
+
if (seen.has(stored.objectId))
|
|
39
|
+
return;
|
|
40
|
+
seen.add(stored.objectId);
|
|
41
|
+
out.push({
|
|
42
|
+
identity: this.#identity(stored),
|
|
43
|
+
title: stored.payload.title,
|
|
44
|
+
matchedBy,
|
|
45
|
+
...(stored.closed ? { closed: true } : {}),
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
for (const known of query.known ?? []) {
|
|
49
|
+
const hit = this.objects.find((stored) => stored.objectId === known.objectId);
|
|
50
|
+
if (hit)
|
|
51
|
+
push(hit, 'known-identity');
|
|
52
|
+
}
|
|
53
|
+
for (const stored of this.objects) {
|
|
54
|
+
if (stored.correlation === query.correlation)
|
|
55
|
+
push(stored, 'correlation');
|
|
56
|
+
}
|
|
57
|
+
if (query.workReference) {
|
|
58
|
+
for (const stored of this.objects) {
|
|
59
|
+
if (stored.workReference === query.workReference)
|
|
60
|
+
push(stored, 'work-reference');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
async create(payload, query) {
|
|
66
|
+
this.createCalls += 1;
|
|
67
|
+
const stored = {
|
|
68
|
+
objectId: `OBJ-${this.objects.length + 1}`,
|
|
69
|
+
payload,
|
|
70
|
+
correlation: query.correlation,
|
|
71
|
+
...(query.workReference ? { workReference: query.workReference } : {}),
|
|
72
|
+
};
|
|
73
|
+
// 먼저 남기고 나서 응답을 잃는다 — 반대로 하면 이 fixture 가 흉내 내는 사고가 아니다.
|
|
74
|
+
this.objects.push(stored);
|
|
75
|
+
if (this.#lose > 0) {
|
|
76
|
+
this.#lose -= 1;
|
|
77
|
+
throw new Error('response lost after the object was created');
|
|
78
|
+
}
|
|
79
|
+
return this.#identity(stored);
|
|
80
|
+
}
|
|
81
|
+
async read(identity) {
|
|
82
|
+
const hit = this.objects.find((stored) => stored.objectId === identity.objectId);
|
|
83
|
+
if (!hit)
|
|
84
|
+
return null;
|
|
85
|
+
return {
|
|
86
|
+
identity: this.#identity(hit),
|
|
87
|
+
title: hit.payload.title,
|
|
88
|
+
...(hit.closed ? { closed: true } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** 밖에서 일어난 일을 흉내 낸다 — 시험이 게시물을 닫거나 남의 것을 심을 때 쓴다. */
|
|
92
|
+
seed(stored) {
|
|
93
|
+
const full = { objectId: stored.objectId ?? `OBJ-${this.objects.length + 1}`, ...stored };
|
|
94
|
+
this.objects.push(full);
|
|
95
|
+
return this.#identity(full);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -28,23 +28,37 @@ export declare function glabAvailable(run: ProcessRunner): Promise<boolean>;
|
|
|
28
28
|
* `glab api` 를 읽기 통로로 감싼 클라이언트. GET 만 다룬다 — 쓰기는 Grant 를 지나야 하고,
|
|
29
29
|
* 그 경로를 우회하는 통로를 여기에 만들지 않는다.
|
|
30
30
|
*/
|
|
31
|
-
export declare class GlabApiClient implements GitLabReader {
|
|
31
|
+
export declare class GlabApiClient implements GitLabReader, GitLabWriter {
|
|
32
32
|
#private;
|
|
33
33
|
constructor(run: ProcessRunner);
|
|
34
34
|
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
35
|
+
post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* 읽기 통로. GitLabClient(토큰)와 GlabApiClient(로그인된 도구) 둘 다 이것이다 —
|
|
38
39
|
* Port 들은 어느 쪽인지 몰라야 한다. 통로가 바뀌었다고 조회 코드가 바뀌면, 통로를 늘릴
|
|
39
40
|
* 때마다 같은 코드가 갈라진다.
|
|
40
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* 쓰기 통로. **읽기와 일부러 갈라 둔다** — 조회 코드가 쓰기를 할 수 있으면 어디서 무엇이
|
|
44
|
+
* 나가는지 아무도 세지 못한다. 이것을 쥔 곳은 조율 표면 하나뿐이고, 거기서 나가는 것은
|
|
45
|
+
* 공개 payload 로 제한돼 있다.
|
|
46
|
+
*
|
|
47
|
+
* 승인(Grant)을 지나는 외부 Action 과는 다른 경로다. 그쪽은 사람이 승인한 단일 행동을
|
|
48
|
+
* 그대로 내보내는 통로이고, 이쪽은 물어본 것이 밖에 실제로 있게 하는 조율 행위다.
|
|
49
|
+
* 둘을 한 통로로 합치면 승인의 의미가 흐려진다.
|
|
50
|
+
*/
|
|
51
|
+
export interface GitLabWriter {
|
|
52
|
+
post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
|
|
53
|
+
}
|
|
41
54
|
export interface GitLabReader {
|
|
42
55
|
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
43
56
|
}
|
|
44
|
-
export declare class GitLabClient implements GitLabReader {
|
|
57
|
+
export declare class GitLabClient implements GitLabReader, GitLabWriter {
|
|
45
58
|
#private;
|
|
46
59
|
constructor(deps: GitLabClientDeps);
|
|
47
60
|
get<T>(path: string): Promise<GitLabResponse<T>>;
|
|
61
|
+
post<T>(path: string, body: Record<string, unknown>): Promise<GitLabResponse<T>>;
|
|
48
62
|
}
|
|
49
63
|
/** `group/sub/project!19` 또는 `group/project#7` 을 쪼갠다. */
|
|
50
64
|
export declare function parseRef(reference: string): {
|
|
@@ -39,6 +39,18 @@ export class GlabApiClient {
|
|
|
39
39
|
return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
async post(path, body) {
|
|
43
|
+
// 값 하나가 한 필드다. 문자열을 만들어 붙이지 않는다 — 그렇게 하면 본문에 개행이나
|
|
44
|
+
// 따옴표가 있을 때 조용히 다른 것이 나간다.
|
|
45
|
+
const fields = Object.entries(body).flatMap(([key, value]) => value === undefined ? [] : ['-f', `${key}=${String(value)}`]);
|
|
46
|
+
try {
|
|
47
|
+
const stdout = await this.#run('glab', ['api', '--method', 'POST', path.replace(/^\//, ''), ...fields]);
|
|
48
|
+
return { ok: true, status: 201, data: JSON.parse(stdout) };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return { ok: false, status: 0, data: null, error: String(error.message ?? error).slice(0, 200) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
42
54
|
}
|
|
43
55
|
export class GitLabClient {
|
|
44
56
|
#token;
|
|
@@ -65,6 +77,21 @@ export class GitLabClient {
|
|
|
65
77
|
...(nextPage ? { nextPage } : {}),
|
|
66
78
|
};
|
|
67
79
|
}
|
|
80
|
+
async post(path, body) {
|
|
81
|
+
const response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: {
|
|
84
|
+
accept: 'application/json',
|
|
85
|
+
'content-type': 'application/json',
|
|
86
|
+
'private-token': this.#token,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok) {
|
|
91
|
+
return { ok: false, status: response.status, data: null, error: `HTTP ${response.status}` };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, status: response.status, data: (await response.json()) };
|
|
94
|
+
}
|
|
68
95
|
}
|
|
69
96
|
/** `group/sub/project!19` 또는 `group/project#7` 을 쪼갠다. */
|
|
70
97
|
export function parseRef(reference) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CoordinationSurfacePort, PublicPayload, SurfaceCandidate, SurfaceQuery, SurfaceSnapshot } from '../../ports/coordination-surface.ts';
|
|
2
|
+
import type { RemoteIdentity } from '../../core/runtime/coordination.ts';
|
|
3
|
+
import { type GitLabReader, type GitLabWriter } from './client.ts';
|
|
4
|
+
/** 상관 관계를 심는 방법. 라벨 하나이고, 그 안에 들어가는 것은 기대 id 뿐이다. */
|
|
5
|
+
export declare const CORRELATION_LABEL = "asc-coordination";
|
|
6
|
+
export type GitLabCoordinationDeps = {
|
|
7
|
+
reader: GitLabReader;
|
|
8
|
+
/** 없으면 만들 수 없다. **없는 것을 있는 척하지 않는다** — create 가 그렇게 답한다. */
|
|
9
|
+
writer?: GitLabWriter;
|
|
10
|
+
project: string;
|
|
11
|
+
};
|
|
12
|
+
export declare class GitLabCoordinationSurface implements CoordinationSurfacePort {
|
|
13
|
+
#private;
|
|
14
|
+
readonly id = "gitlab";
|
|
15
|
+
constructor(deps: GitLabCoordinationDeps);
|
|
16
|
+
find(query: SurfaceQuery): Promise<SurfaceCandidate[]>;
|
|
17
|
+
create(payload: PublicPayload, query: SurfaceQuery): Promise<RemoteIdentity>;
|
|
18
|
+
read(identity: Pick<RemoteIdentity, 'objectType' | 'objectId'>): Promise<SurfaceSnapshot | null>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// GitLab 조율 표면 — 밖에 있는 durable 한 조율 게시물 하나를 찾고, 만들고, 되읽는다.
|
|
2
|
+
//
|
|
3
|
+
// 이 파일은 provider 를 아는 자리다. Core 는 `CoordinationSurfacePort` 만 알고 여기 이름을
|
|
4
|
+
// 모른다 (C-09 §6). 그래서 여기서 GitLab 어휘를 쓰는 것은 계약대로이고, Core 쪽에 같은
|
|
5
|
+
// 어휘가 새는 것은 결함이다.
|
|
6
|
+
//
|
|
7
|
+
// 신원을 무엇으로 삼는가가 이 adapter 의 핵심 결정이다. 주소(web_url)는 쓰지 않는다 —
|
|
8
|
+
// 호스트가 바뀌고, 프로젝트가 옮겨 다니고, 같은 게시물이 다른 주소로 보인다. 대신
|
|
9
|
+
// `group/project#iid` 를 쓴다. 이 값은 그 시스템 안에서 안정적이고, 스스로 어느 프로젝트인지
|
|
10
|
+
// 말하므로 되읽기가 다른 정보를 필요로 하지 않는다.
|
|
11
|
+
import { encodeProject, parseRef } from "./client.js";
|
|
12
|
+
/** 상관 관계를 심는 방법. 라벨 하나이고, 그 안에 들어가는 것은 기대 id 뿐이다. */
|
|
13
|
+
export const CORRELATION_LABEL = 'asc-coordination';
|
|
14
|
+
const correlationLabel = (correlation) => `${CORRELATION_LABEL}:${correlation}`;
|
|
15
|
+
export class GitLabCoordinationSurface {
|
|
16
|
+
id = 'gitlab';
|
|
17
|
+
#reader;
|
|
18
|
+
#writer;
|
|
19
|
+
#project;
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
this.#reader = deps.reader;
|
|
22
|
+
this.#writer = deps.writer;
|
|
23
|
+
this.#project = deps.project;
|
|
24
|
+
}
|
|
25
|
+
#identity(issue, project = this.#project) {
|
|
26
|
+
return {
|
|
27
|
+
adapter: this.id,
|
|
28
|
+
objectType: 'issue',
|
|
29
|
+
// 주소가 아니라 이 값이 정본이다.
|
|
30
|
+
objectId: `${project}#${issue.iid}`,
|
|
31
|
+
resource: project,
|
|
32
|
+
...(issue.web_url ? { locator: issue.web_url } : {}),
|
|
33
|
+
...(issue.updated_at ? { revisionMarker: issue.updated_at } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
#candidate(issue, matchedBy) {
|
|
37
|
+
return {
|
|
38
|
+
identity: this.#identity(issue),
|
|
39
|
+
title: issue.title,
|
|
40
|
+
matchedBy,
|
|
41
|
+
...(issue.state === 'closed' ? { closed: true } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async find(query) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const add = (candidate) => {
|
|
48
|
+
if (seen.has(candidate.identity.objectId))
|
|
49
|
+
return;
|
|
50
|
+
seen.add(candidate.identity.objectId);
|
|
51
|
+
out.push(candidate);
|
|
52
|
+
};
|
|
53
|
+
// 1) 이미 아는 게시물. 가장 강한 근거이고, 여기서 걸리면 나머지를 볼 이유가 없다.
|
|
54
|
+
for (const known of query.known ?? []) {
|
|
55
|
+
const snapshot = await this.read(known);
|
|
56
|
+
if (snapshot) {
|
|
57
|
+
add({
|
|
58
|
+
identity: snapshot.identity,
|
|
59
|
+
title: snapshot.title,
|
|
60
|
+
matchedBy: 'known-identity',
|
|
61
|
+
...(snapshot.closed ? { closed: true } : {}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// 2) 우리가 심어 둔 상관 관계. 라벨은 그 시스템이 색인하는 값이라 제목처럼 흔들리지 않는다.
|
|
66
|
+
const labelled = await this.#reader.get(`/projects/${encodeProject(this.#project)}/issues?labels=${encodeURIComponent(correlationLabel(query.correlation))}&state=all&per_page=20`);
|
|
67
|
+
if (!labelled.ok) {
|
|
68
|
+
// 못 찾은 것을 없는 것으로 넘기지 않는다. 호출자가 이 차이로 판단한다.
|
|
69
|
+
throw new Error(labelled.error ?? `issue search failed (${labelled.status})`);
|
|
70
|
+
}
|
|
71
|
+
for (const issue of labelled.data ?? [])
|
|
72
|
+
add(this.#candidate(issue, 'correlation'));
|
|
73
|
+
// 3) 작업 항목으로 훑는다. **약한 근거다** — 같은 작업에 여러 조율이 붙을 수 있다.
|
|
74
|
+
if (query.workReference) {
|
|
75
|
+
const searched = await this.#reader.get(`/projects/${encodeProject(this.#project)}/issues?search=${encodeURIComponent(query.workReference)}&in=title,description&state=all&per_page=20`);
|
|
76
|
+
if (searched.ok)
|
|
77
|
+
for (const issue of searched.data ?? [])
|
|
78
|
+
add(this.#candidate(issue, 'work-reference'));
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
async create(payload, query) {
|
|
83
|
+
if (!this.#writer)
|
|
84
|
+
throw new Error('this binding has no write channel — nothing was created');
|
|
85
|
+
// **여기를 지나는 것이 밖으로 나가는 전부다.** payload 밖의 값은 이 함수에 오지 않고,
|
|
86
|
+
// 라벨 하나만 우리가 덧붙인다 — 다음 회차가 이 게시물을 다시 찾는 근거다.
|
|
87
|
+
const labels = [...(payload.labels ?? []), correlationLabel(query.correlation)].join(',');
|
|
88
|
+
const created = await this.#writer.post(`/projects/${encodeProject(this.#project)}/issues`, {
|
|
89
|
+
title: payload.title,
|
|
90
|
+
description: payload.body,
|
|
91
|
+
labels,
|
|
92
|
+
});
|
|
93
|
+
if (!created.ok || !created.data) {
|
|
94
|
+
throw new Error(created.error ?? `issue create failed (${created.status})`);
|
|
95
|
+
}
|
|
96
|
+
return this.#identity(created.data);
|
|
97
|
+
}
|
|
98
|
+
async read(identity) {
|
|
99
|
+
const parsed = parseRef(identity.objectId);
|
|
100
|
+
if (!parsed || parsed.kind !== 'issue')
|
|
101
|
+
return null;
|
|
102
|
+
const response = await this.#reader.get(`/projects/${encodeProject(parsed.project)}/issues/${parsed.iid}`);
|
|
103
|
+
if (!response.ok || !response.data)
|
|
104
|
+
return null;
|
|
105
|
+
return {
|
|
106
|
+
identity: this.#identity(response.data, parsed.project),
|
|
107
|
+
title: response.data.title,
|
|
108
|
+
...(response.data.state === 'closed' ? { closed: true } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -21,6 +21,12 @@ declare abstract class GitLabBase {
|
|
|
21
21
|
* 할 일 목록(todo)으로 증분을 받는다. GitHub의 알림과 같은 자리이고, 같은 한계를 갖는다 —
|
|
22
22
|
* 지정이 빠지면 오지 않는다. 그래서 Inventory가 따로 있다.
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* 두 시각 중 늦은 것. ISO 문자열이지만 **문자열 비교를 하지 않는다** — 이 API 는 계정 설정에
|
|
26
|
+
* 따라 `+09:00` 과 `Z` 를 섞어 돌려주고, 그 둘은 사전순과 시간순이 다르다. 해석이 안 되는
|
|
27
|
+
* 값은 비교하지 않고 있는 쪽을 택한다.
|
|
28
|
+
*/
|
|
29
|
+
export declare function laterOf(a: string | undefined, b: string | undefined): string | undefined;
|
|
24
30
|
export declare class GitLabEventSource extends GitLabBase implements EventSource {
|
|
25
31
|
readonly id = "gitlab-todo";
|
|
26
32
|
cursorFrom(since: string): Cursor;
|
|
@@ -33,6 +33,22 @@ class GitLabBase {
|
|
|
33
33
|
* 할 일 목록(todo)으로 증분을 받는다. GitHub의 알림과 같은 자리이고, 같은 한계를 갖는다 —
|
|
34
34
|
* 지정이 빠지면 오지 않는다. 그래서 Inventory가 따로 있다.
|
|
35
35
|
*/
|
|
36
|
+
/**
|
|
37
|
+
* 두 시각 중 늦은 것. ISO 문자열이지만 **문자열 비교를 하지 않는다** — 이 API 는 계정 설정에
|
|
38
|
+
* 따라 `+09:00` 과 `Z` 를 섞어 돌려주고, 그 둘은 사전순과 시간순이 다르다. 해석이 안 되는
|
|
39
|
+
* 값은 비교하지 않고 있는 쪽을 택한다.
|
|
40
|
+
*/
|
|
41
|
+
export function laterOf(a, b) {
|
|
42
|
+
if (!a)
|
|
43
|
+
return b;
|
|
44
|
+
if (!b)
|
|
45
|
+
return a;
|
|
46
|
+
const ta = Date.parse(a);
|
|
47
|
+
const tb = Date.parse(b);
|
|
48
|
+
if (Number.isNaN(ta) || Number.isNaN(tb))
|
|
49
|
+
return a >= b ? a : b;
|
|
50
|
+
return tb > ta ? b : a;
|
|
51
|
+
}
|
|
36
52
|
export class GitLabEventSource extends GitLabBase {
|
|
37
53
|
id = 'gitlab-todo';
|
|
38
54
|
cursorFrom(since) {
|
|
@@ -46,8 +62,13 @@ export class GitLabEventSource extends GitLabBase {
|
|
|
46
62
|
const response = await this.client.get(`/todos?${params}`);
|
|
47
63
|
if (!response.ok || !response.data)
|
|
48
64
|
return { events: [], cursor };
|
|
65
|
+
// 이번 회차(페이지 전체)에서 본 가장 늦은 시각. **반환 순서를 믿지 않는다** — 이 목록은
|
|
66
|
+
// 최신순으로 오므로 마지막 항목이 가장 오래된 것이었고, 그것을 다음 기준선으로 삼자
|
|
67
|
+
// 커서가 옛 시각에 고정돼 같은 것을 매 회차 다시 읽었다. 기준선은 뒤로 가지 않는다.
|
|
68
|
+
let highWater = parsed.highWater ?? parsed.since;
|
|
49
69
|
const events = [];
|
|
50
70
|
for (const todo of response.data) {
|
|
71
|
+
highWater = laterOf(highWater, todo.updated_at);
|
|
51
72
|
// 기준선 이전 것은 버린다. provider가 since를 지원하지 않아 여기서 거른다 —
|
|
52
73
|
// 겹쳐 읽고 key로 중복을 거르는 편이 놓치는 것보다 싸다 (OM §10.5).
|
|
53
74
|
if (parsed.since && todo.updated_at < parsed.since)
|
|
@@ -69,8 +90,13 @@ export class GitLabEventSource extends GitLabBase {
|
|
|
69
90
|
});
|
|
70
91
|
}
|
|
71
92
|
const next = response.nextPage
|
|
72
|
-
? JSON.stringify({
|
|
73
|
-
|
|
93
|
+
? JSON.stringify({
|
|
94
|
+
...(parsed.since ? { since: parsed.since } : {}),
|
|
95
|
+
page: response.nextPage,
|
|
96
|
+
...(highWater ? { highWater } : {}),
|
|
97
|
+
})
|
|
98
|
+
: // 페이지를 다 돌았으면 기준선은 지금까지 본 최대값이다 — 이전 기준선보다 앞서지 않는다.
|
|
99
|
+
JSON.stringify(highWater ? { since: highWater } : {});
|
|
74
100
|
return { events, cursor: next, ...(response.nextPage ? { hasMore: true } : {}) };
|
|
75
101
|
}
|
|
76
102
|
}
|
|
@@ -149,6 +175,7 @@ export class GitLabResourceContext extends GitLabBase {
|
|
|
149
175
|
body: note.body ?? '',
|
|
150
176
|
// 여기는 resolvable 여부를 알려준다 — 아는 것만 적는다.
|
|
151
177
|
...(note.resolvable ? { unresolved: note.resolved !== true } : {}),
|
|
178
|
+
...(note.system ? { system: true } : {}),
|
|
152
179
|
}));
|
|
153
180
|
}
|
|
154
181
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CanonicalSnapshot } from '../../core/model/entities.ts';
|
|
2
|
+
import type { BaselineQuery, ExternalAction, ExternalActionResult, ScmPort, ThreadSnapshot } from '../../ports/scm.ts';
|
|
3
|
+
export type LocalCanonicalDeps = {
|
|
4
|
+
cwd: string;
|
|
5
|
+
/** sourceId → 어느 ref 를 읽는가. Profile 이 준다. */
|
|
6
|
+
sourceRefs: Readonly<Record<string, {
|
|
7
|
+
ref: string;
|
|
8
|
+
remote?: string;
|
|
9
|
+
}>>;
|
|
10
|
+
exec?: (args: readonly string[], cwd: string) => Promise<string>;
|
|
11
|
+
};
|
|
12
|
+
export declare class LocalCanonicalReader implements ScmPort {
|
|
13
|
+
#private;
|
|
14
|
+
readonly id = "git";
|
|
15
|
+
constructor(deps: LocalCanonicalDeps);
|
|
16
|
+
getBaselines(queries: readonly BaselineQuery[]): Promise<CanonicalSnapshot[]>;
|
|
17
|
+
/** 스레드는 이 통로의 것이 아니다. 모르는 것을 아는 척하지 않는다. */
|
|
18
|
+
getThread(reference: string): Promise<ThreadSnapshot>;
|
|
19
|
+
execute(action: ExternalAction): Promise<ExternalActionResult>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 정본 baseline 을 **선언된 대로** 읽는다.
|
|
2
|
+
//
|
|
3
|
+
// Profile 은 정본 갈래마다 provider 를 적는다. `git` 이라고 적힌 갈래는 이 checkout 이
|
|
4
|
+
// 이미 들고 있는 사실이다 — 원격 API 로 갈 이유가 없고, 갈 수 있다는 보장도 없다.
|
|
5
|
+
// 실제로 그 자리에서 한 provider 의 API 로만 읽으려다, 다른 host 를 쓰는 프로젝트에서
|
|
6
|
+
// 세션 발급이 통째로 막혔다. 정본을 못 읽으면 무엇을 딛고 시작하는지 적을 수 없기 때문이다.
|
|
7
|
+
//
|
|
8
|
+
// 여기는 **읽기만** 한다. 쓰기 통로가 아니고, 그 사실을 execute 가 그대로 말한다.
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
export class LocalCanonicalReader {
|
|
13
|
+
id = 'git';
|
|
14
|
+
#cwd;
|
|
15
|
+
#refs;
|
|
16
|
+
#exec;
|
|
17
|
+
constructor(deps) {
|
|
18
|
+
this.#cwd = deps.cwd;
|
|
19
|
+
this.#refs = deps.sourceRefs;
|
|
20
|
+
this.#exec =
|
|
21
|
+
deps.exec ??
|
|
22
|
+
(async (args, cwd) => {
|
|
23
|
+
const { stdout } = await run('git', [...args], { cwd });
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async getBaselines(queries) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const query of queries) {
|
|
30
|
+
const configured = this.#refs[query.sourceId];
|
|
31
|
+
const ref = query.ref ?? configured?.ref;
|
|
32
|
+
if (!ref) {
|
|
33
|
+
out.push({ sourceId: query.sourceId, baseline: 'unknown' });
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// 원격 추적 ref 를 먼저 본다. 로컬 브랜치를 정본처럼 읽으면 내 작업이 정본이 된다.
|
|
37
|
+
const remote = configured?.remote;
|
|
38
|
+
const candidates = remote ? [`${remote}/${ref}`, ref] : [ref];
|
|
39
|
+
let baseline = 'unknown';
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const sha = await this.#exec(['rev-parse', '--verify', `${candidate}^{commit}`], this.#cwd).catch(() => '');
|
|
42
|
+
if (sha) {
|
|
43
|
+
baseline = sha;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
out.push({ sourceId: query.sourceId, baseline });
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** 스레드는 이 통로의 것이 아니다. 모르는 것을 아는 척하지 않는다. */
|
|
52
|
+
async getThread(reference) {
|
|
53
|
+
return { reference, lastEventId: 'unknown', missing: true };
|
|
54
|
+
}
|
|
55
|
+
async execute(action) {
|
|
56
|
+
return { ok: false, error: `this canonical reader performs no external action: ${action.action}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -314,7 +314,8 @@ function renderControlState(state) {
|
|
|
314
314
|
'# Execution',
|
|
315
315
|
`활성 Block: ${state.activeBlock ?? '없음'}`,
|
|
316
316
|
`활성 세션: ${state.activeSessions.join(', ') || '없음'}`,
|
|
317
|
-
|
|
317
|
+
// 객체를 그대로 문자열에 넣으면 "[object Object]" 가 남는다 — 실제 state.md 에서 그렇게 보였다.
|
|
318
|
+
`Write Boundary 점유: ${state.writeBoundaryOccupancy.map((o) => `${o.sessionId} → ${o.paths.join(' ')}`).join(', ') || '없음'}`,
|
|
318
319
|
`승인 대기: ${state.awaitingController.join(', ') || '없음'}`,
|
|
319
320
|
'',
|
|
320
321
|
'# Monitoring',
|
|
@@ -22,6 +22,23 @@ const xml = (value) => value.replace(/&/g, '&').replace(/</g, '<').replac
|
|
|
22
22
|
*/
|
|
23
23
|
export function launchAgentPlist(command) {
|
|
24
24
|
const args = [command.program, ...command.args].map((arg) => ` <string>${xml(arg)}</string>`).join('\n');
|
|
25
|
+
// 키를 정렬해 적는다 — 같은 환경이면 같은 문서여야 CURRENT 판정이 선다.
|
|
26
|
+
const env = Object.entries(command.environment ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
27
|
+
const environment = env.length === 0
|
|
28
|
+
? ''
|
|
29
|
+
: ` <key>EnvironmentVariables</key>
|
|
30
|
+
<dict>
|
|
31
|
+
${env.map(([key, value]) => ` <key>${xml(key)}</key>\n <string>${xml(value)}</string>`).join('\n')}
|
|
32
|
+
</dict>
|
|
33
|
+
`;
|
|
34
|
+
// 회차가 무엇을 했는지 볼 자리. 없으면 launchd 는 출력을 버리고, 실패는 종료 코드 하나로만 남는다.
|
|
35
|
+
const logs = command.logPath
|
|
36
|
+
? ` <key>StandardOutPath</key>
|
|
37
|
+
<string>${xml(command.logPath)}</string>
|
|
38
|
+
<key>StandardErrorPath</key>
|
|
39
|
+
<string>${xml(command.logPath)}</string>
|
|
40
|
+
`
|
|
41
|
+
: '';
|
|
25
42
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
26
43
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
27
44
|
<plist version="1.0">
|
|
@@ -32,7 +49,7 @@ export function launchAgentPlist(command) {
|
|
|
32
49
|
<array>
|
|
33
50
|
${args}
|
|
34
51
|
</array>
|
|
35
|
-
<key>StartInterval</key>
|
|
52
|
+
${environment}${logs} <key>StartInterval</key>
|
|
36
53
|
<integer>${command.intervalSeconds}</integer>
|
|
37
54
|
<key>RunAtLoad</key>
|
|
38
55
|
<true/>
|
|
@@ -24,7 +24,10 @@ Description=ASC persistent runtime
|
|
|
24
24
|
[Service]
|
|
25
25
|
Type=oneshot
|
|
26
26
|
ExecStart=${[command.program, ...command.args].map(quote).join(' ')}
|
|
27
|
-
|
|
27
|
+
${Object.entries(command.environment ?? {})
|
|
28
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
29
|
+
.map(([key, value]) => `Environment=${quote(`${key}=${value}`)}\n`)
|
|
30
|
+
.join('')}`;
|
|
28
31
|
}
|
|
29
32
|
export function timerUnit(command) {
|
|
30
33
|
return `[Unit]
|
|
@@ -32,8 +32,8 @@ export declare const IngressRecord: z.ZodObject<{
|
|
|
32
32
|
raw?: unknown;
|
|
33
33
|
hints?: Record<string, unknown> | undefined;
|
|
34
34
|
};
|
|
35
|
-
seq: number;
|
|
36
35
|
receivedAt: string;
|
|
36
|
+
seq: number;
|
|
37
37
|
}, {
|
|
38
38
|
event: {
|
|
39
39
|
detectedAt: string;
|
|
@@ -42,8 +42,8 @@ export declare const IngressRecord: z.ZodObject<{
|
|
|
42
42
|
raw?: unknown;
|
|
43
43
|
hints?: Record<string, unknown> | undefined;
|
|
44
44
|
};
|
|
45
|
-
seq: number;
|
|
46
45
|
receivedAt: string;
|
|
46
|
+
seq: number;
|
|
47
47
|
}>;
|
|
48
48
|
export type IngressRecord = z.infer<typeof IngressRecord>;
|
|
49
49
|
export type VerifyOutcome = {
|