agents-can-communicate 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 +21 -0
- package/README.md +176 -0
- package/bin/acc-hook.mjs +53 -0
- package/bin/acc-mcp.mjs +46 -0
- package/bin/acc.mjs +24 -0
- package/docs/CAPABILITIES.md +153 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.claude-plugin/plugin.json +5 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/hooks/hooks.json +56 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +106 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +278 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +17 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/hooks.json +55 -0
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +81 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +133 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +235 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +6 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/hooks/hooks.json +69 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +74 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/.kimi-plugin/plugin.json +9 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +217 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +73 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +125 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +216 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +12 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/config-merge.mjs +215 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +217 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +55 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +148 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +16 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +95 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/package.json +12 -0
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +163 -0
- package/node_modules/@agents-can-communicate/cli/src/claim-spelling.mjs +79 -0
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +149 -0
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +85 -0
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +41 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +10 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +107 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +344 -0
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +109 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +58 -0
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +126 -0
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +151 -0
- package/node_modules/@agents-can-communicate/core/package.json +12 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +180 -0
- package/node_modules/@agents-can-communicate/core/src/communication.mjs +334 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +86 -0
- package/node_modules/@agents-can-communicate/core/src/notify.mjs +95 -0
- package/node_modules/@agents-can-communicate/core/src/participants.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +56 -0
- package/node_modules/@agents-can-communicate/core/src/service.mjs +44 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +190 -0
- package/node_modules/@agents-can-communicate/core/src/status.mjs +132 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +273 -0
- package/node_modules/@agents-can-communicate/core/src/tasks.mjs +238 -0
- package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +109 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +12 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +379 -0
- package/node_modules/@agents-can-communicate/installer/package.json +10 -0
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +58 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +79 -0
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +6 -0
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +162 -0
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +62 -0
- package/node_modules/@agents-can-communicate/mcp-server/package.json +12 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +58 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +250 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +201 -0
- package/node_modules/@agents-can-communicate/protocol/package.json +12 -0
- package/node_modules/@agents-can-communicate/protocol/src/config.mjs +151 -0
- package/node_modules/@agents-can-communicate/protocol/src/envelopes.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/errors.mjs +30 -0
- package/node_modules/@agents-can-communicate/protocol/src/fields.mjs +103 -0
- package/node_modules/@agents-can-communicate/protocol/src/ids.mjs +25 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +9 -0
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +74 -0
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +175 -0
- package/node_modules/@agents-can-communicate/protocol/src/states.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +12 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +135 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +67 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +4 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +87 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/record-id.mjs +44 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +114 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +80 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +55 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +292 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +87 -0
- package/package.json +81 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { AccError, EXIT } from "./errors.mjs";
|
|
2
|
+
import { flag, id, invalid, listOf, nullable, oneOf, plainObject, positiveInteger,
|
|
3
|
+
resourceUri, sequence, text, timestamp } from "./fields.mjs";
|
|
4
|
+
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
const line = text();
|
|
8
|
+
const prose = text({ max: 4000, multiline: true });
|
|
9
|
+
const summary = text({ max: 280 });
|
|
10
|
+
const artifactKind = oneOf("file", "git", "url", "report", "image", "data");
|
|
11
|
+
|
|
12
|
+
/** @typedef {{ sequence: string, eventId: string, workspaceId: string,
|
|
13
|
+
* actorSessionId: string, type: string, occurredAt: string, payload: object }} AccEvent */
|
|
14
|
+
/** @typedef {{ cursor: string, events: AccEvent[] }} EventPage */
|
|
15
|
+
/** @typedef {{ kind: string, priority: number, sourceId: string, summary: string }} AttentionItem */
|
|
16
|
+
/** @typedef {{ workspace: object, participants: object[], sessions: object[],
|
|
17
|
+
* intents: object[], workstreams: object[], tasks: object[], claims: object[] }} WorkspaceSnapshot */
|
|
18
|
+
|
|
19
|
+
// Field names stay mappable to the A2A Agent Card, Task, Message, and Artifact
|
|
20
|
+
// concepts (spec section 11) without importing any A2A transport.
|
|
21
|
+
const artifactRef = (value, field) => {
|
|
22
|
+
plainObject(value, field);
|
|
23
|
+
const known = new Set(["kind", "uri", "description", "sha256"]);
|
|
24
|
+
for (const key of Object.keys(value)) {
|
|
25
|
+
if (!known.has(key)) invalid(`${field}.${key}`, "is not a known artifact field", value[key]);
|
|
26
|
+
}
|
|
27
|
+
artifactKind(value.kind, `${field}.kind`);
|
|
28
|
+
resourceUri(value.uri, `${field}.uri`);
|
|
29
|
+
line(value.description, `${field}.description`);
|
|
30
|
+
if (value.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(value.sha256)) {
|
|
31
|
+
invalid(`${field}.sha256`, "must be a lowercase SHA-256 digest", value.sha256);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Every event ACC itself appends. Closed on purpose: `type` used to be free
|
|
37
|
+
// text, so a record written by hand validated cleanly and `acc doctor` called
|
|
38
|
+
// the store healthy. That is not theoretical - a session that could not run the
|
|
39
|
+
// CLI wrote its own events, inventing `task.completed`, and the store reported
|
|
40
|
+
// no problem.
|
|
41
|
+
const EVENT_TYPES = Object.freeze([
|
|
42
|
+
"workspace.materialised",
|
|
43
|
+
"session.opened", "session.closed",
|
|
44
|
+
"intent.published", "intent.cleared",
|
|
45
|
+
"workstream.created", "workstream.coordinator_acquired",
|
|
46
|
+
"workstream.coordinator_released",
|
|
47
|
+
"task.created", "task.claimed", "task.transitioned", "task.unblocked",
|
|
48
|
+
"task.declined", "task.released",
|
|
49
|
+
"claim.acquired", "claim.released", "claim.renewed", "claim.force_released",
|
|
50
|
+
"message.sent", "decision.recorded", "handoff.created",
|
|
51
|
+
// Delivery transitions and request outcomes are templated from their state,
|
|
52
|
+
// so the set has to carry each one they can produce.
|
|
53
|
+
...["recorded", "queued", "injected", "seen", "acknowledged", "failed"]
|
|
54
|
+
.map(state => `message.${state}`),
|
|
55
|
+
...["accepted", "declined", "review", "done", "released"]
|
|
56
|
+
.map(outcome => `work.${outcome}`),
|
|
57
|
+
"work.requested",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const eventType = oneOf(...EVENT_TYPES);
|
|
61
|
+
|
|
62
|
+
const RECORDS = Object.freeze({
|
|
63
|
+
workspace: { workspaceId: id, displayName: line, source: oneOf("config", "git", "directory"),
|
|
64
|
+
roots: listOf(line), createdAt: timestamp },
|
|
65
|
+
|
|
66
|
+
participant: { participantId: id, workspaceId: id, displayName: line,
|
|
67
|
+
kind: oneOf("agent", "human"), createdAt: timestamp },
|
|
68
|
+
|
|
69
|
+
// `enforcement` and `lifecycle` are what this session's harness can actually
|
|
70
|
+
// do, declared at attach. The harness name does not imply them: the same
|
|
71
|
+
// client guards or does not depending on its model and its approval mode, and
|
|
72
|
+
// a peer deciding whether to rely on a claim needs the answer, not the brand.
|
|
73
|
+
// A workspace spans every worktree of one repository, so the workspace id
|
|
74
|
+
// cannot say which checkout a session is sitting in. Recorded at attach from
|
|
75
|
+
// what discovery already resolved: without it nobody can tell which worktrees
|
|
76
|
+
// have an owner, and asking cannot answer for the agents that are not running
|
|
77
|
+
// - which are exactly the ones a clean-up is looking for.
|
|
78
|
+
session: { sessionId: id, participantId: id, workspaceId: id, generation: id,
|
|
79
|
+
harness: line, state: oneOf("open", "closed"), parentSessionId: nullable(id),
|
|
80
|
+
checkoutRoot: nullable(line), branch: nullable(line),
|
|
81
|
+
enforcement: oneOf("guarded", "advisory"), lifecycle: oneOf("managed", "manual"),
|
|
82
|
+
heartbeatCadenceMs: positiveInteger, startedAt: timestamp, heartbeatAt: timestamp },
|
|
83
|
+
|
|
84
|
+
intent: { sessionId: id, workspaceId: id, summary,
|
|
85
|
+
mode: oneOf("observe", "explore", "edit", "review", "coordinate", "wait"),
|
|
86
|
+
resourceHints: listOf(resourceUri), workstreamId: nullable(id),
|
|
87
|
+
state: oneOf("active", "blocked", "waiting", "done"), updatedAt: timestamp },
|
|
88
|
+
|
|
89
|
+
workstream: { workstreamId: id, workspaceId: id, title: line, objective: prose,
|
|
90
|
+
coordinatorSessionId: nullable(id),
|
|
91
|
+
state: oneOf("open", "paused", "complete", "cancelled"), createdAt: timestamp },
|
|
92
|
+
|
|
93
|
+
// Two assignees, deliberately. `assigneeParticipantId` is who the work is
|
|
94
|
+
// for and survives that agent restarting; `assigneeSessionId` is the exact
|
|
95
|
+
// session doing it right now and dies with the process. Asking one field to
|
|
96
|
+
// be both would either lose the request when a terminal closes or claim a
|
|
97
|
+
// dead session is still working.
|
|
98
|
+
task: { taskId: id, workstreamId: nullable(id), workspaceId: id, title: line,
|
|
99
|
+
state: oneOf("pending", "in_progress", "review", "done", "blocked"),
|
|
100
|
+
assigneeParticipantId: nullable(id), assigneeSessionId: nullable(id),
|
|
101
|
+
// Who asked. Without it nothing could tell the requester that their work
|
|
102
|
+
// was accepted, declined or finished - the task knew who it was for and
|
|
103
|
+
// had no idea who was waiting on it.
|
|
104
|
+
requestedByParticipantId: nullable(id),
|
|
105
|
+
dependsOn: listOf(id), acceptance: listOf(line), detail: nullable(prose),
|
|
106
|
+
createdAt: timestamp },
|
|
107
|
+
|
|
108
|
+
claim: { claimId: id, workspaceId: id, ownerSessionId: id, resource: resourceUri,
|
|
109
|
+
mode: oneOf("shared", "exclusive"), enforcement: oneOf("advisory", "guarded"),
|
|
110
|
+
reason: line, acquiredAt: timestamp, expiresAt: timestamp, generation: id },
|
|
111
|
+
|
|
112
|
+
// `fromParticipantId` beside the session: a session ends, and the one fact
|
|
113
|
+
// that has to outlive it is who was speaking. Resolving the sender by looking
|
|
114
|
+
// its session up meant the record could never be retired, and an agent whose
|
|
115
|
+
// client had restarted stopped being told about its own unanswered question.
|
|
116
|
+
message: { messageId: id, workspaceId: id, fromSessionId: id,
|
|
117
|
+
fromParticipantId: id, toParticipantIds: listOf(id),
|
|
118
|
+
type: oneOf("note", "question", "answer", "contract_request", "contract_response",
|
|
119
|
+
"decision_proposal", "decision_result", "blocker", "review_request",
|
|
120
|
+
"review_result", "handoff", "work_request", "work_response"),
|
|
121
|
+
subject: line, body: prose, priority: oneOf("low", "normal", "high", "urgent"),
|
|
122
|
+
workstreamId: nullable(id), taskId: nullable(id), inReplyTo: nullable(id),
|
|
123
|
+
requiresAck: flag, artifacts: listOf(artifactRef), sentAt: timestamp },
|
|
124
|
+
|
|
125
|
+
receipt: { messageId: id, workspaceId: id, recipientParticipantId: id,
|
|
126
|
+
state: oneOf("recorded", "queued", "injected", "seen", "acknowledged", "failed"),
|
|
127
|
+
updatedAt: timestamp },
|
|
128
|
+
|
|
129
|
+
decision: { decisionId: id, workspaceId: id, workstreamId: nullable(id), title: line,
|
|
130
|
+
outcome: prose, authority: oneOf("human", "workstream", "policy"),
|
|
131
|
+
decidedBy: listOf(id), evidence: listOf(artifactRef), supersedes: nullable(id),
|
|
132
|
+
decidedAt: timestamp },
|
|
133
|
+
|
|
134
|
+
artifact: { kind: artifactKind, uri: resourceUri, description: line },
|
|
135
|
+
|
|
136
|
+
handoff: { handoffId: id, workspaceId: id, fromSessionId: id, toParticipantId: nullable(id),
|
|
137
|
+
goal: line, status: oneOf("complete", "partial", "blocked"), completed: listOf(line),
|
|
138
|
+
remaining: listOf(line), blockers: listOf(line), claimsToRelease: listOf(resourceUri),
|
|
139
|
+
verification: listOf(artifactRef), artifacts: listOf(artifactRef), createdAt: timestamp },
|
|
140
|
+
|
|
141
|
+
event: { sequence, eventId: id, workspaceId: id, actorSessionId: id, type: eventType,
|
|
142
|
+
occurredAt: timestamp, payload: plainObject },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const RECORD_KINDS = Object.freeze(Object.keys(RECORDS));
|
|
146
|
+
|
|
147
|
+
export function validateRecord(kind, value) {
|
|
148
|
+
const fields = RECORDS[kind];
|
|
149
|
+
if (fields === undefined) {
|
|
150
|
+
throw new AccError(EXIT.DATA, `unknown record kind: ${kind}`, { kind });
|
|
151
|
+
}
|
|
152
|
+
plainObject(value, kind);
|
|
153
|
+
if (value.schemaVersion !== SCHEMA_VERSION) {
|
|
154
|
+
throw new AccError(EXIT.DATA,
|
|
155
|
+
`${kind} has an unknown schemaVersion: ${String(value.schemaVersion)}`,
|
|
156
|
+
{ kind, schemaVersion: value.schemaVersion });
|
|
157
|
+
}
|
|
158
|
+
for (const key of Object.keys(value)) {
|
|
159
|
+
if (key === "schemaVersion" || key === "extensions") continue;
|
|
160
|
+
if (!Object.hasOwn(fields, key)) {
|
|
161
|
+
invalid(`${kind}.${key}`, "is not a known field", value[key]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const [field, check] of Object.entries(fields)) {
|
|
165
|
+
if (!Object.hasOwn(value, field)) {
|
|
166
|
+
throw new AccError(EXIT.DATA, `${kind} requires ${field}`, { kind, field });
|
|
167
|
+
}
|
|
168
|
+
check(value[field], field);
|
|
169
|
+
}
|
|
170
|
+
// Forward-compatible metadata is tolerated only inside a named container, so
|
|
171
|
+
// an older reader can round-trip a newer writer's record without guessing
|
|
172
|
+
// which unknown top-level keys are safe.
|
|
173
|
+
if (Object.hasOwn(value, "extensions")) plainObject(value.extensions, "extensions");
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { AccError, EXIT } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
// recorded -> queued -> injected -> seen -> acknowledged, with failed branching
|
|
4
|
+
// off before the message was ever exposed. States are monotonic: one
|
|
5
|
+
// recipient's receipt can only move forwards, and never rewrites another
|
|
6
|
+
// recipient's state.
|
|
7
|
+
export const DELIVERY_STATES = Object.freeze(
|
|
8
|
+
["recorded", "queued", "injected", "seen", "acknowledged", "failed"]);
|
|
9
|
+
|
|
10
|
+
const DELIVERY_NEXT = Object.freeze({
|
|
11
|
+
recorded: ["queued", "injected", "seen", "acknowledged", "failed"],
|
|
12
|
+
queued: ["injected", "seen", "acknowledged", "failed"],
|
|
13
|
+
injected: ["seen", "acknowledged"],
|
|
14
|
+
seen: ["acknowledged"],
|
|
15
|
+
acknowledged: [],
|
|
16
|
+
failed: [],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const TASK_STATES = Object.freeze(
|
|
20
|
+
["pending", "in_progress", "review", "done", "blocked"]);
|
|
21
|
+
|
|
22
|
+
const TASK_NEXT = Object.freeze({
|
|
23
|
+
pending: ["in_progress", "blocked"],
|
|
24
|
+
in_progress: ["review", "done", "blocked"],
|
|
25
|
+
review: ["in_progress", "done", "blocked"],
|
|
26
|
+
blocked: ["pending", "in_progress"],
|
|
27
|
+
done: [],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function step(machine, allowed, label, current, next) {
|
|
31
|
+
if (!machine.includes(current)) {
|
|
32
|
+
throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(current)}`,
|
|
33
|
+
{ current, next });
|
|
34
|
+
}
|
|
35
|
+
if (!machine.includes(next)) {
|
|
36
|
+
throw new AccError(EXIT.DATA, `unknown ${label} state: ${String(next)}`,
|
|
37
|
+
{ current, next });
|
|
38
|
+
}
|
|
39
|
+
// Re-declaring the current state is idempotent. Adapters retry at safe
|
|
40
|
+
// points, and a repeated receipt is not a protocol violation.
|
|
41
|
+
if (current === next) return next;
|
|
42
|
+
if (!allowed[current].includes(next)) {
|
|
43
|
+
throw new AccError(EXIT.CONFLICT,
|
|
44
|
+
`illegal ${label} transition from ${current} to ${next}`, { current, next });
|
|
45
|
+
}
|
|
46
|
+
return next;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function advanceDelivery(current, next) {
|
|
50
|
+
return step(DELIVERY_STATES, DELIVERY_NEXT, "delivery", current, next);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function transitionTask(current, next) {
|
|
54
|
+
return step(TASK_STATES, TASK_NEXT, "task", current, next);
|
|
55
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, open, readdir, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
|
+
|
|
7
|
+
import { assertManagedDirectory, ensureManagedDirectory } from "./safe-directory.mjs";
|
|
8
|
+
import { readRegularNoFollow } from "./safe-file.mjs";
|
|
9
|
+
|
|
10
|
+
async function syncDirectory(directory) {
|
|
11
|
+
const handle = await open(directory, "r");
|
|
12
|
+
try {
|
|
13
|
+
await handle.sync();
|
|
14
|
+
} finally {
|
|
15
|
+
await handle.close();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function unlinkIfPresent(filePath) {
|
|
20
|
+
try {
|
|
21
|
+
await unlink(filePath);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error.code !== "ENOENT") throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function encode(value) {
|
|
28
|
+
const serialised = JSON.stringify(value, null, 2);
|
|
29
|
+
if (serialised === undefined) {
|
|
30
|
+
throw new AccError(EXIT.DATA, "record is not JSON serializable", { value: typeof value });
|
|
31
|
+
}
|
|
32
|
+
return Buffer.from(`${serialised}\n`, "utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function bytesIfPresent(filePath, root) {
|
|
36
|
+
try {
|
|
37
|
+
return await readRegularNoFollow(filePath, root);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error.code === "ENOENT") return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Publish bytes atomically.
|
|
46
|
+
*
|
|
47
|
+
* Two modes, and the distinction matters: immutable evidence - events, journal
|
|
48
|
+
* entries, audits - is published by link(), so overwriting is impossible by
|
|
49
|
+
* construction rather than by a check that could race, and re-publishing
|
|
50
|
+
* identical bytes is idempotent while different bytes fail closed. Materialised
|
|
51
|
+
* state is mutable by design, which is what generations exist for, so it is
|
|
52
|
+
* published by rename(). Conflating the two makes every state update fail.
|
|
53
|
+
*
|
|
54
|
+
* @returns {Promise<"published" | "already_published">}
|
|
55
|
+
*/
|
|
56
|
+
export async function publishAtomic(destination, bytes, { root, tmpDir, replace = false }) {
|
|
57
|
+
const destinationDir = path.dirname(destination);
|
|
58
|
+
await Promise.all([
|
|
59
|
+
ensureManagedDirectory(root, tmpDir),
|
|
60
|
+
ensureManagedDirectory(root, destinationDir),
|
|
61
|
+
]);
|
|
62
|
+
const temporary = path.join(tmpDir, `${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`);
|
|
63
|
+
const handle = await open(temporary, "wx");
|
|
64
|
+
try {
|
|
65
|
+
await handle.writeFile(bytes);
|
|
66
|
+
await handle.sync();
|
|
67
|
+
} finally {
|
|
68
|
+
await handle.close();
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (replace) {
|
|
72
|
+
await rename(temporary, destination);
|
|
73
|
+
await syncDirectory(destinationDir);
|
|
74
|
+
return "published";
|
|
75
|
+
}
|
|
76
|
+
await link(temporary, destination);
|
|
77
|
+
await syncDirectory(destinationDir);
|
|
78
|
+
return "published";
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== "EEXIST") throw error;
|
|
81
|
+
const existing = await bytesIfPresent(destination, root);
|
|
82
|
+
if (existing !== null && existing.equals(bytes)) return "already_published";
|
|
83
|
+
throw new AccError(EXIT.CONFLICT, "record already published with different bytes",
|
|
84
|
+
{ destination });
|
|
85
|
+
} finally {
|
|
86
|
+
await unlinkIfPresent(temporary);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readJsonIfPresent(filePath, root) {
|
|
91
|
+
const bytes = await bytesIfPresent(filePath, root);
|
|
92
|
+
if (bytes === null) return null;
|
|
93
|
+
try {
|
|
94
|
+
return { value: JSON.parse(bytes.toString("utf8")), bytes };
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Named in the message, not only in the details: human mode prints the
|
|
97
|
+
// message alone, and "invalid JSON record" sent a reader looking through a
|
|
98
|
+
// whole workspace for a file the error already knew.
|
|
99
|
+
throw new AccError(EXIT.DATA, `invalid JSON record: ${filePath}`,
|
|
100
|
+
{ filePath, cause: error.message });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function listDirectoryEntries(dirPath, { root, readDirectory = readdir } = {}) {
|
|
105
|
+
let before;
|
|
106
|
+
try {
|
|
107
|
+
before = await assertManagedDirectory(root, dirPath);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code === "ENOENT") return [];
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await readDirectory(dirPath, { withFileTypes: true });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code === "ENOENT") return [];
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
const after = await assertManagedDirectory(root, dirPath);
|
|
120
|
+
if (before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino) {
|
|
121
|
+
throw new AccError(EXIT.DATA, "managed directory changed while listing", { dirPath, root });
|
|
122
|
+
}
|
|
123
|
+
return entries;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function listJsonFiles(dirPath, options) {
|
|
127
|
+
return (await listDirectoryEntries(dirPath, options))
|
|
128
|
+
.filter(entry => entry.isFile() && entry.name.endsWith(".json"))
|
|
129
|
+
.map(entry => path.join(dirPath, entry.name))
|
|
130
|
+
.sort((left, right) => left.localeCompare(right));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function removeIfPresent(filePath) {
|
|
134
|
+
await unlinkIfPresent(filePath);
|
|
135
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
6
|
+
|
|
7
|
+
export const STORE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export function identityPath(paths) {
|
|
10
|
+
return path.join(paths.root, "protocol.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertIdentity(record, workspaceId, filePath) {
|
|
14
|
+
if (record?.storeVersion !== STORE_VERSION) {
|
|
15
|
+
throw new AccError(EXIT.DATA, "unknown store version", { filePath,
|
|
16
|
+
storeVersion: record?.storeVersion });
|
|
17
|
+
}
|
|
18
|
+
if (record.workspaceId !== workspaceId) {
|
|
19
|
+
throw new AccError(EXIT.DATA, "store belongs to a different workspace",
|
|
20
|
+
{ filePath, expected: workspaceId, actual: record.workspaceId });
|
|
21
|
+
}
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Establish or verify the store's identity before anything else happens.
|
|
27
|
+
* Opening a directory that already belongs to another workspace must fail
|
|
28
|
+
* rather than quietly adopt it: an initialisation that silently rewrites a
|
|
29
|
+
* foreign store is the failure the reconciled prototype fails closed on, and
|
|
30
|
+
* the same rule applies here.
|
|
31
|
+
*/
|
|
32
|
+
export async function requireStoreIdentity(paths, { workspaceId, clock, create = true }) {
|
|
33
|
+
assertPortableId(workspaceId, "workspace id");
|
|
34
|
+
const filePath = identityPath(paths);
|
|
35
|
+
const found = await readJsonIfPresent(filePath, paths.root);
|
|
36
|
+
if (found !== null) return assertIdentity(found.value, workspaceId, filePath);
|
|
37
|
+
if (!create) {
|
|
38
|
+
throw new AccError(EXIT.DATA, "store is not initialised", { filePath });
|
|
39
|
+
}
|
|
40
|
+
const record = { storeVersion: STORE_VERSION, workspaceId, initialisedAt: clock.now() };
|
|
41
|
+
try {
|
|
42
|
+
await publishAtomic(filePath, encode(record), { root: paths.root, tmpDir: paths.tmp });
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Losing this race is not a failure. Two agents starting together in a
|
|
45
|
+
// workspace neither has opened before is the ordinary case, and the two
|
|
46
|
+
// identity documents they write differ in one field - the moment each was
|
|
47
|
+
// written - so the second was refused for "different bytes" and that agent
|
|
48
|
+
// could not attach at all.
|
|
49
|
+
//
|
|
50
|
+
// What the store has to refuse is a directory belonging to a *different*
|
|
51
|
+
// workspace, and that is decided below by reading what is actually there
|
|
52
|
+
// rather than by whose bytes arrived first.
|
|
53
|
+
if (error.code !== EXIT.CONFLICT) throw error;
|
|
54
|
+
}
|
|
55
|
+
const published = await readJsonIfPresent(filePath, paths.root);
|
|
56
|
+
return assertIdentity(published.value, workspaceId, filePath);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readStoreIdentity(paths) {
|
|
60
|
+
const found = await readJsonIfPresent(identityPath(paths), paths.root);
|
|
61
|
+
if (found === null) return null;
|
|
62
|
+
if (found.value?.storeVersion !== STORE_VERSION) {
|
|
63
|
+
throw new AccError(EXIT.DATA, "unknown store version",
|
|
64
|
+
{ filePath: identityPath(paths), storeVersion: found.value?.storeVersion });
|
|
65
|
+
}
|
|
66
|
+
return found.value;
|
|
67
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// CoordinationStore backed by the reconciled filesystem protocol.
|
|
2
|
+
export { openFilesystemStore, storePaths, ZERO_CURSOR } from "./store.mjs";
|
|
3
|
+
export { diagnoseFilesystemStore, repairFilesystemStore } from "./recovery.mjs";
|
|
4
|
+
export { readStoreIdentity, requireStoreIdentity, STORE_VERSION } from "./identity.mjs";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
import { encode, listJsonFiles, publishAtomic, readJsonIfPresent, removeIfPresent }
|
|
6
|
+
from "./atomic-json.mjs";
|
|
7
|
+
|
|
8
|
+
export const JOURNAL_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
// A journal entry is written only after the transaction callback has succeeded
|
|
11
|
+
// and every byte is known. Its existence therefore means "this transaction was
|
|
12
|
+
// decided", which is what makes roll-forward - rather than rollback - the
|
|
13
|
+
// correct recovery. Roll-forward is idempotent because publication is
|
|
14
|
+
// no-replace and identical bytes are accepted as already published.
|
|
15
|
+
export function journalPath(paths, transactionId) {
|
|
16
|
+
return path.join(paths.journal, `${transactionId}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function journalEntry(transactionId, firstSequence, publications, startedAt) {
|
|
20
|
+
return {
|
|
21
|
+
journalVersion: JOURNAL_VERSION,
|
|
22
|
+
transactionId,
|
|
23
|
+
firstSequence,
|
|
24
|
+
startedAt,
|
|
25
|
+
publications: publications.map(item => ({
|
|
26
|
+
path: item.path,
|
|
27
|
+
// A removal is journalled like any other publication, so a crash between
|
|
28
|
+
// two deletions replays to the same end state rather than a partial one.
|
|
29
|
+
bytes: item.remove === true ? null : item.bytes.toString("base64"),
|
|
30
|
+
replace: item.replace === true,
|
|
31
|
+
remove: item.remove === true,
|
|
32
|
+
})),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeJournalEntry(paths, options, entry) {
|
|
37
|
+
await publishAtomic(journalPath(paths, entry.transactionId), encode(entry), options);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function retireJournalEntry(paths, transactionId) {
|
|
42
|
+
await removeIfPresent(journalPath(paths, transactionId));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readOpenJournals(paths, root) {
|
|
46
|
+
const files = await listJsonFiles(paths.journal, { root });
|
|
47
|
+
const entries = [];
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const found = await readJsonIfPresent(file, root);
|
|
50
|
+
if (found === null) continue;
|
|
51
|
+
const entry = found.value;
|
|
52
|
+
if (entry?.journalVersion !== JOURNAL_VERSION) {
|
|
53
|
+
throw new AccError(EXIT.DATA, "unknown journal version", { file,
|
|
54
|
+
journalVersion: entry?.journalVersion });
|
|
55
|
+
}
|
|
56
|
+
if (path.basename(file, ".json") !== entry.transactionId) {
|
|
57
|
+
throw new AccError(EXIT.DATA, "journal entry does not match its filename", { file });
|
|
58
|
+
}
|
|
59
|
+
entries.push(entry);
|
|
60
|
+
}
|
|
61
|
+
return entries.sort((left, right) => left.firstSequence.localeCompare(right.firstSequence));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Publishing every listed file and then retiring the entry. Already-published
|
|
65
|
+
// identical bytes are accepted, differing bytes fail closed, so running this
|
|
66
|
+
// twice changes nothing and a genuine conflict is never papered over.
|
|
67
|
+
export async function rollForward(paths, options, entry) {
|
|
68
|
+
const published = [];
|
|
69
|
+
for (const publication of entry.publications) {
|
|
70
|
+
const destination = path.resolve(options.root, publication.path);
|
|
71
|
+
if (publication.remove === true) {
|
|
72
|
+
await removeIfPresent(destination);
|
|
73
|
+
published.push(publication.path);
|
|
74
|
+
await options.failAt?.(`after:${publication.path}`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const bytes = Buffer.from(publication.bytes, "base64");
|
|
78
|
+
const outcome = await publishAtomic(destination, bytes,
|
|
79
|
+
{ ...options, replace: publication.replace === true });
|
|
80
|
+
if (outcome === "published") published.push(publication.path);
|
|
81
|
+
// Seam for the crash-window tests: abort between two publications of the
|
|
82
|
+
// same decided transaction.
|
|
83
|
+
await options.failAt?.(`after:${publication.path}`);
|
|
84
|
+
}
|
|
85
|
+
await retireJournalEntry(paths, entry.transactionId);
|
|
86
|
+
return published;
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { AccError, EXIT, assertPortableId } from "@agents-can-communicate/protocol";
|
|
4
|
+
|
|
5
|
+
// State files carry an envelope rather than the bare record. The envelope names
|
|
6
|
+
// the kind, the id, and the generation, so a file that is moved or renamed can
|
|
7
|
+
// be detected instead of being trusted because of where it happens to sit.
|
|
8
|
+
// This is the generalisation of the reconciled prototype's filename binding.
|
|
9
|
+
export function stateEnvelope(kind, id, generation, record) {
|
|
10
|
+
return { kind, id, generation, record };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function statePath(paths, kind, id) {
|
|
14
|
+
assertPortableId(kind, "record kind");
|
|
15
|
+
assertPortableId(id, "record id");
|
|
16
|
+
return path.join(paths.state, kind, `${id}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function assertStateBinding(envelope, kind, id, filePath) {
|
|
20
|
+
const shaped = envelope !== null && typeof envelope === "object"
|
|
21
|
+
&& typeof envelope.kind === "string" && typeof envelope.id === "string"
|
|
22
|
+
&& typeof envelope.generation === "string";
|
|
23
|
+
if (!shaped) {
|
|
24
|
+
throw new AccError(EXIT.DATA, "state record is not a valid envelope", { filePath });
|
|
25
|
+
}
|
|
26
|
+
if (envelope.kind !== kind || envelope.id !== id) {
|
|
27
|
+
throw new AccError(EXIT.DATA, "state record does not match its path",
|
|
28
|
+
{ filePath, expected: { kind, id }, actual: { kind: envelope.kind, id: envelope.id } });
|
|
29
|
+
}
|
|
30
|
+
return envelope;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function eventPath(paths, sequence) {
|
|
34
|
+
return path.join(paths.events, `${sequence}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertEventBinding(event, filePath) {
|
|
38
|
+
const sequence = path.basename(filePath, ".json");
|
|
39
|
+
if (event?.sequence !== sequence) {
|
|
40
|
+
throw new AccError(EXIT.DATA, "event record does not match its filename",
|
|
41
|
+
{ filePath, expected: sequence, actual: event?.sequence });
|
|
42
|
+
}
|
|
43
|
+
return event;
|
|
44
|
+
}
|