agents-pod 0.3.2 → 0.3.5
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/CHANGELOG.md +87 -0
- package/README.md +2 -0
- package/examples/feishu/README.md +81 -11
- package/examples/feishu/feishu_client.py +27 -7
- package/examples/feishu/feishu_drain.py +144 -31
- package/examples/feishu/test_feishu_drain.py +157 -0
- package/package.json +1 -1
- package/src/adapters.ts +303 -0
- package/src/cli.ts +186 -4
- package/src/doctor.ts +75 -1
- package/src/extension/index.ts +15 -1
- package/src/paths.ts +5 -0
- package/src/relocate.ts +395 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Offline tests for the drain's intake rules (issue #27, ADR 0024).
|
|
2
|
+
|
|
3
|
+
Nothing here touches the network, the environment or a store: whether one
|
|
4
|
+
message becomes an Inbox item is a pure decision, so the tests feed hand-written
|
|
5
|
+
message dicts and assert on the JSONL a pass would print.
|
|
6
|
+
|
|
7
|
+
Run from this directory: python3 -m unittest test_feishu_drain -v
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import unittest
|
|
13
|
+
from contextlib import redirect_stderr
|
|
14
|
+
|
|
15
|
+
import feishu_drain as drain
|
|
16
|
+
|
|
17
|
+
CHAT_ID = "oc_chat"
|
|
18
|
+
CHAT_NAME = "测试群"
|
|
19
|
+
BASE_AT = 1_700_000_000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def text_message(msg_id, text, at=BASE_AT, sender_type="user", mentions=None):
|
|
23
|
+
message = {
|
|
24
|
+
"message_id": msg_id,
|
|
25
|
+
"msg_type": "text",
|
|
26
|
+
"create_time": str(at * 1000),
|
|
27
|
+
"sender": {
|
|
28
|
+
"id": "ou_human",
|
|
29
|
+
"id_type": "open_id",
|
|
30
|
+
"sender_type": sender_type,
|
|
31
|
+
"tenant_key": "tenant",
|
|
32
|
+
},
|
|
33
|
+
"body": {"content": json.dumps({"text": text}, ensure_ascii=False)},
|
|
34
|
+
}
|
|
35
|
+
if mentions is not None:
|
|
36
|
+
message["mentions"] = mentions
|
|
37
|
+
return message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def system_message(msg_id, at=BASE_AT + 1):
|
|
41
|
+
"""The measured shape: a template body and an all-empty sender."""
|
|
42
|
+
return {
|
|
43
|
+
"message_id": msg_id,
|
|
44
|
+
"msg_type": "system",
|
|
45
|
+
"create_time": str(at * 1000),
|
|
46
|
+
"sender": {"id": "", "id_type": "", "sender_type": "", "tenant_key": ""},
|
|
47
|
+
"body": {
|
|
48
|
+
"content": json.dumps(
|
|
49
|
+
{
|
|
50
|
+
"template": "{from_user} invited {to_chatters} to the chat.",
|
|
51
|
+
"from_user": ["徐福洋"],
|
|
52
|
+
"to_chatters": ["邹文", "大肥鱼"],
|
|
53
|
+
},
|
|
54
|
+
ensure_ascii=False,
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_pass(messages, *, cursor=None, require_mention=False, open_id=None, max_items=40):
|
|
61
|
+
"""One pass over a message list, as main() would assemble it."""
|
|
62
|
+
state = drain.new_pass(require_mention=require_mention, open_id=open_id)
|
|
63
|
+
kinds = [drain.absorb(state, msg, CHAT_ID, CHAT_NAME) for msg in messages]
|
|
64
|
+
collected = state["collected"]
|
|
65
|
+
collected.sort(key=lambda item: item["at"])
|
|
66
|
+
emitted = collected[:max_items]
|
|
67
|
+
lines = drain.output_lines(
|
|
68
|
+
emitted,
|
|
69
|
+
drain.advance_cursor(cursor, emitted, collected, state["consumed_at"]),
|
|
70
|
+
)
|
|
71
|
+
return kinds, [json.loads(line) for line in lines]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SystemEventTest(unittest.TestCase):
|
|
75
|
+
def test_system_only_pass_prints_cursor_and_no_item(self):
|
|
76
|
+
kinds, lines = run_pass([system_message("om_sys", at=BASE_AT)])
|
|
77
|
+
|
|
78
|
+
self.assertEqual(kinds, ["system"])
|
|
79
|
+
self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
|
|
80
|
+
|
|
81
|
+
def test_system_then_human_yields_only_the_human_and_cursor_past_both(self):
|
|
82
|
+
kinds, lines = run_pass(
|
|
83
|
+
[
|
|
84
|
+
system_message("om_sys", at=BASE_AT),
|
|
85
|
+
text_message("om_human", "大家好", at=BASE_AT + 5),
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
self.assertEqual(kinds, ["system", "item"])
|
|
90
|
+
self.assertEqual(len(lines), 2)
|
|
91
|
+
self.assertEqual(lines[0]["id"], "om_human")
|
|
92
|
+
self.assertEqual(lines[0]["body"], "大家好")
|
|
93
|
+
self.assertNotIn("system", lines[0]["body"])
|
|
94
|
+
self.assertEqual(lines[1], {"cursor": str(BASE_AT + 5)})
|
|
95
|
+
|
|
96
|
+
def test_system_event_is_logged_once_on_stderr(self):
|
|
97
|
+
stderr = io.StringIO()
|
|
98
|
+
with redirect_stderr(stderr):
|
|
99
|
+
drain.note_system_event(system_message("om_sys"), CHAT_ID)
|
|
100
|
+
|
|
101
|
+
self.assertEqual(
|
|
102
|
+
stderr.getvalue().strip(),
|
|
103
|
+
"[feishu-drain] skip system event om_sys in %s" % CHAT_ID,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ExistingFiltersTest(unittest.TestCase):
|
|
108
|
+
def test_require_mention_drops_unmentioned_human(self):
|
|
109
|
+
mentioned = [
|
|
110
|
+
{
|
|
111
|
+
"key": "@bot",
|
|
112
|
+
"id": {"union_id": "on_bot", "user_id": "u_bot", "open_id": "ou_bot"},
|
|
113
|
+
"name": "pod",
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
kinds, lines = run_pass(
|
|
117
|
+
[
|
|
118
|
+
text_message("om_plain", "没人叫我", at=BASE_AT),
|
|
119
|
+
text_message("om_at", "@pod 在吗", at=BASE_AT + 1, mentions=mentioned),
|
|
120
|
+
],
|
|
121
|
+
require_mention=True,
|
|
122
|
+
open_id="ou_bot",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.assertEqual(kinds, ["skip", "item"])
|
|
126
|
+
self.assertEqual([line["id"] for line in lines[:-1]], ["om_at"])
|
|
127
|
+
self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
|
|
128
|
+
|
|
129
|
+
def test_app_sender_is_still_dropped(self):
|
|
130
|
+
kinds, lines = run_pass(
|
|
131
|
+
[text_message("om_bot", "机器人发言", at=BASE_AT, sender_type="app")]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.assertEqual(kinds, ["skip"])
|
|
135
|
+
self.assertEqual(lines, [{"cursor": str(BASE_AT)}])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CursorTest(unittest.TestCase):
|
|
139
|
+
def test_nothing_read_prints_nothing(self):
|
|
140
|
+
_, lines = run_pass([], cursor=BASE_AT)
|
|
141
|
+
|
|
142
|
+
self.assertEqual(lines, [])
|
|
143
|
+
|
|
144
|
+
def test_cursor_does_not_jump_a_queued_item(self):
|
|
145
|
+
messages = [
|
|
146
|
+
text_message("om_1", "一", at=BASE_AT),
|
|
147
|
+
text_message("om_2", "二", at=BASE_AT + 1),
|
|
148
|
+
text_message("om_3", "三", at=BASE_AT + 2),
|
|
149
|
+
]
|
|
150
|
+
_, lines = run_pass(messages, max_items=2)
|
|
151
|
+
|
|
152
|
+
self.assertEqual([line["id"] for line in lines[:-1]], ["om_1", "om_2"])
|
|
153
|
+
self.assertEqual(lines[-1], {"cursor": str(BASE_AT + 1)})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
unittest.main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-pod",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Supervision for long-running coding agents: detached pods, heartbeat liveness, cron-ticked reconcile, and a Pi extension for spawning and handoff",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
package/src/adapters.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { copyFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { adaptersFile, storeRoot } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adapter copies are the operator's files, outside the repository (ADRs 0023,
|
|
9
|
+
* 0024): core ships an example, the operator copies it beside their
|
|
10
|
+
* credentials. The gap this module closes is *provenance*: `--check` proves an
|
|
11
|
+
* adapter answers, never that it is current, so a copy can sit a release behind
|
|
12
|
+
* and every `doctor` line still reads ✓ (issue #26).
|
|
13
|
+
*
|
|
14
|
+
* Nothing here writes into a copy unless a human ran `adapter sync`: a package
|
|
15
|
+
* update must not silently change a command that holds live credentials.
|
|
16
|
+
*/
|
|
17
|
+
export interface AdapterFile {
|
|
18
|
+
name: string;
|
|
19
|
+
sha256: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One registered copy: where it lives, and the package state it came from. */
|
|
23
|
+
export interface AdapterRecord {
|
|
24
|
+
name: string;
|
|
25
|
+
dir: string;
|
|
26
|
+
packageVersion: string;
|
|
27
|
+
files: AdapterFile[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Files a copy owns but the package must never overwrite: credentials and the
|
|
32
|
+
* caches a platform writes for itself. Enforced even though `examples/` ships
|
|
33
|
+
* none of them — a future example that did would otherwise leak a credential
|
|
34
|
+
* over an operator's real one.
|
|
35
|
+
*/
|
|
36
|
+
const RUNTIME_ONLY = new Set([".env", ".token.json", ".bot.json", "state.json"]);
|
|
37
|
+
|
|
38
|
+
/** `same` needs no action; `update` is a pristine copy one version behind. */
|
|
39
|
+
export type AdapterFileState = "same" | "update" | "missing-in-copy" | "local-edit";
|
|
40
|
+
|
|
41
|
+
export interface AdapterFilePlan {
|
|
42
|
+
name: string;
|
|
43
|
+
state: AdapterFileState;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AdapterSyncPlan {
|
|
47
|
+
name: string;
|
|
48
|
+
dir: string;
|
|
49
|
+
packageDir: string;
|
|
50
|
+
packageVersion: string;
|
|
51
|
+
files: AdapterFilePlan[];
|
|
52
|
+
/** Package entries that are not files an operator copy can take. */
|
|
53
|
+
skipped: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A fact for `doctor`; the caller decides how to print it (ADR 0027). */
|
|
57
|
+
export interface AdapterFact {
|
|
58
|
+
mark: "ok" | "bad" | "note";
|
|
59
|
+
text: string;
|
|
60
|
+
next?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The installed package root, derived from this module's own location: `src/`
|
|
65
|
+
* and `examples/` are siblings there, which is what makes the copy source the
|
|
66
|
+
* *installed* package rather than whatever working tree is nearby.
|
|
67
|
+
*/
|
|
68
|
+
export function packageRoot(): string {
|
|
69
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readPackageVersion(root = packageRoot()): Promise<string> {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
|
75
|
+
version?: string;
|
|
76
|
+
};
|
|
77
|
+
return parsed.version ?? "unknown";
|
|
78
|
+
} catch {
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function adapterPackageDir(name: string, root = packageRoot()): string {
|
|
84
|
+
return join(root, "examples", name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readAdapters(): Promise<AdapterRecord[]> {
|
|
88
|
+
try {
|
|
89
|
+
const parsed: unknown = JSON.parse(await readFile(adaptersFile(), "utf8"));
|
|
90
|
+
return Array.isArray(parsed) ? (parsed as AdapterRecord[]) : [];
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Atomic like every other store write: a crash never leaves half a manifest. */
|
|
98
|
+
export async function writeAdapters(records: AdapterRecord[]): Promise<void> {
|
|
99
|
+
const file = adaptersFile();
|
|
100
|
+
await mkdir(dirname(file), { recursive: true });
|
|
101
|
+
const tmp = `${file}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}.tmp`;
|
|
102
|
+
await writeFile(tmp, `${JSON.stringify(records, null, 2)}\n`);
|
|
103
|
+
await rename(tmp, file);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sha256(content: Buffer | string): string {
|
|
107
|
+
return createHash("sha256").update(content).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function fileSha(path: string): Promise<string | undefined> {
|
|
111
|
+
try {
|
|
112
|
+
return sha256(await readFile(path));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The files a copy takes: regular files at the top of the package's example
|
|
121
|
+
* directory. `__pycache__` and a `.pyc` would be the package's build residue,
|
|
122
|
+
* not the adapter; runtime caches are excluded by name in `RUNTIME_ONLY`.
|
|
123
|
+
*/
|
|
124
|
+
async function packageFiles(packageDir: string): Promise<{ files: string[]; skipped: string[] }> {
|
|
125
|
+
const entries = await readdir(packageDir, { withFileTypes: true });
|
|
126
|
+
const files: string[] = [];
|
|
127
|
+
const skipped: string[] = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (RUNTIME_ONLY.has(entry.name) || entry.name === "__pycache__" || entry.name.endsWith(".pyc")) {
|
|
130
|
+
skipped.push(entry.name);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!entry.isFile()) {
|
|
134
|
+
skipped.push(entry.name);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
files.push(entry.name);
|
|
138
|
+
}
|
|
139
|
+
files.sort();
|
|
140
|
+
return { files, skipped };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What a sync would do, without doing it. A file whose copy matches the
|
|
145
|
+
* manifest but not the package is one version behind (`update`); a file that
|
|
146
|
+
* matches neither is the operator's own edit (`local-edit`) and is reported,
|
|
147
|
+
* never overwritten without `--force`.
|
|
148
|
+
*/
|
|
149
|
+
export async function planAdapterSync(
|
|
150
|
+
name: string,
|
|
151
|
+
options: { root?: string; dest?: string } = {},
|
|
152
|
+
): Promise<AdapterSyncPlan> {
|
|
153
|
+
const root = options.root ?? packageRoot();
|
|
154
|
+
const packageDir = adapterPackageDir(name, root);
|
|
155
|
+
const info = await stat(packageDir).catch(() => undefined);
|
|
156
|
+
if (info === undefined || !info.isDirectory()) {
|
|
157
|
+
throw new Error(`the package ships no adapter named ${name} (${packageDir})`);
|
|
158
|
+
}
|
|
159
|
+
// The store root, not $HOME: a test store and a secondary install must not
|
|
160
|
+
// sync into the machine-wide copy (ADR 0004).
|
|
161
|
+
const dir = resolve(options.dest ?? join(storeRoot(), "adapters"));
|
|
162
|
+
const packageVersion = await readPackageVersion(root);
|
|
163
|
+
const { files, skipped } = await packageFiles(packageDir);
|
|
164
|
+
const recorded = (await readAdapters()).find((record) => record.name === name);
|
|
165
|
+
|
|
166
|
+
const plan: AdapterFilePlan[] = [];
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const wanted = await fileSha(join(packageDir, file));
|
|
169
|
+
const present = await fileSha(join(dir, file));
|
|
170
|
+
if (present === undefined) {
|
|
171
|
+
plan.push({ name: file, state: "missing-in-copy" });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (present === wanted) {
|
|
175
|
+
plan.push({ name: file, state: "same" });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const known = recorded?.files.find((entry) => entry.name === file)?.sha256;
|
|
179
|
+
plan.push({ name: file, state: known !== undefined && known === present ? "update" : "local-edit" });
|
|
180
|
+
}
|
|
181
|
+
return { name, dir, packageDir, packageVersion, files: plan, skipped };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Writes the copy and records its provenance; returns what it changed. */
|
|
185
|
+
export async function applyAdapterSync(
|
|
186
|
+
plan: AdapterSyncPlan,
|
|
187
|
+
options: { force?: boolean } = {},
|
|
188
|
+
): Promise<{ copied: string[]; kept: string[]; manifest: string }> {
|
|
189
|
+
await mkdir(plan.dir, { recursive: true });
|
|
190
|
+
const copied: string[] = [];
|
|
191
|
+
const kept: string[] = [];
|
|
192
|
+
const files: AdapterFile[] = [];
|
|
193
|
+
|
|
194
|
+
for (const file of plan.files) {
|
|
195
|
+
const from = join(plan.packageDir, file.name);
|
|
196
|
+
const to = join(plan.dir, file.name);
|
|
197
|
+
if (file.state === "local-edit" && options.force !== true) {
|
|
198
|
+
kept.push(file.name);
|
|
199
|
+
} else if (file.state !== "same") {
|
|
200
|
+
await copyFile(from, to);
|
|
201
|
+
copied.push(file.name);
|
|
202
|
+
}
|
|
203
|
+
// The manifest records the *package* hash, never what a kept local edit
|
|
204
|
+
// happens to be: otherwise a kept file would later read as "stale" instead
|
|
205
|
+
// of "the operator edited this", which is the fact worth reporting.
|
|
206
|
+
files.push({ name: file.name, sha256: sha256(await readFile(from)) });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const records = await readAdapters();
|
|
210
|
+
const record: AdapterRecord = {
|
|
211
|
+
name: plan.name,
|
|
212
|
+
dir: plan.dir,
|
|
213
|
+
packageVersion: plan.packageVersion,
|
|
214
|
+
files: files.sort((left, right) => left.name.localeCompare(right.name)),
|
|
215
|
+
};
|
|
216
|
+
const remaining = records.filter((entry) => entry.name !== plan.name);
|
|
217
|
+
remaining.push(record);
|
|
218
|
+
await writeAdapters(remaining);
|
|
219
|
+
return { copied, kept, manifest: adaptersFile() };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Provenance facts for `doctor` (ADR 0027). Only registered copies are
|
|
224
|
+
* reported: an unregistered directory is not something core was asked to
|
|
225
|
+
* watch, and guessing would put a platform name into core.
|
|
226
|
+
*/
|
|
227
|
+
export async function adapterFacts(
|
|
228
|
+
records: AdapterRecord[],
|
|
229
|
+
root = packageRoot(),
|
|
230
|
+
): Promise<AdapterFact[]> {
|
|
231
|
+
const facts: AdapterFact[] = [];
|
|
232
|
+
const version = await readPackageVersion(root);
|
|
233
|
+
|
|
234
|
+
for (const record of records) {
|
|
235
|
+
const dirInfo = await stat(record.dir).catch(() => undefined);
|
|
236
|
+
if (dirInfo === undefined || !dirInfo.isDirectory()) {
|
|
237
|
+
facts.push({
|
|
238
|
+
mark: "bad",
|
|
239
|
+
text: `adapters ${record.name}: ${record.dir} is gone (a registered copy)`,
|
|
240
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
241
|
+
});
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (record.files.length === 0) {
|
|
246
|
+
// A record without hashes cannot be compared, so it must not read as ✓:
|
|
247
|
+
// an empty manifest is the shape of "registered before hashes were kept".
|
|
248
|
+
facts.push({
|
|
249
|
+
mark: "note",
|
|
250
|
+
text: `adapters ${record.name}: no file hashes recorded, so nothing can be compared`,
|
|
251
|
+
next: `next: agents-pod adapter sync ${record.name} --apply`,
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const stale: string[] = [];
|
|
257
|
+
const edited: string[] = [];
|
|
258
|
+
const absent: string[] = [];
|
|
259
|
+
for (const file of record.files) {
|
|
260
|
+
const wanted = await fileSha(join(adapterPackageDir(record.name, root), file.name));
|
|
261
|
+
const present = await fileSha(join(record.dir, file.name));
|
|
262
|
+
if (present === undefined) {
|
|
263
|
+
absent.push(file.name);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (wanted === undefined) {
|
|
267
|
+
// The package stopped shipping it: the copy's file is now the only one.
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (present === wanted) continue;
|
|
271
|
+
if (present === file.sha256) stale.push(file.name);
|
|
272
|
+
else edited.push(file.name);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (absent.length > 0) {
|
|
276
|
+
facts.push({
|
|
277
|
+
mark: "bad",
|
|
278
|
+
text: `adapters ${record.name}: ${absent.join(", ")} missing from ${record.dir}`,
|
|
279
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (stale.length > 0) {
|
|
283
|
+
facts.push({
|
|
284
|
+
mark: "bad",
|
|
285
|
+
text:
|
|
286
|
+
`adapters ${record.name}: ${stale.join(", ")} is from ${record.packageVersion}, ` +
|
|
287
|
+
`the package ships ${version}`,
|
|
288
|
+
next: `next: agents-pod adapter sync ${record.name}`,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
if (edited.length > 0) {
|
|
292
|
+
facts.push({
|
|
293
|
+
mark: "note",
|
|
294
|
+
text: `adapters ${record.name}: ${edited.join(", ")} differs from the package (kept: a local edit)`,
|
|
295
|
+
next: ` agents-pod adapter sync ${record.name} --dry-run names it; --force overwrites it`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (absent.length === 0 && stale.length === 0 && edited.length === 0) {
|
|
299
|
+
facts.push({ mark: "ok", text: `adapters ${record.name}: matches package ${version}` });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return facts;
|
|
303
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -46,6 +46,17 @@ import {
|
|
|
46
46
|
readProjects,
|
|
47
47
|
writeProjects,
|
|
48
48
|
} from "./projects.ts";
|
|
49
|
+
import {
|
|
50
|
+
applyProjectMove,
|
|
51
|
+
planProjectMove,
|
|
52
|
+
type ProjectMovePlan,
|
|
53
|
+
} from "./relocate.ts";
|
|
54
|
+
import {
|
|
55
|
+
applyAdapterSync,
|
|
56
|
+
planAdapterSync,
|
|
57
|
+
type AdapterFileState,
|
|
58
|
+
type AdapterSyncPlan,
|
|
59
|
+
} from "./adapters.ts";
|
|
49
60
|
import type { PodRecord } from "./registry.ts";
|
|
50
61
|
import { readPods, withRegistryLock, writePods } from "./registry.ts";
|
|
51
62
|
import {
|
|
@@ -1285,7 +1296,169 @@ async function spawnCmd(
|
|
|
1285
1296
|
return 0;
|
|
1286
1297
|
}
|
|
1287
1298
|
|
|
1288
|
-
|
|
1299
|
+
const PROJECT_USAGE =
|
|
1300
|
+
"Usage: agents-pod project cap [n|unlimited] [--project <dir>]\n" +
|
|
1301
|
+
" agents-pod project move <old> <new> [--apply] [--move-artifacts]";
|
|
1302
|
+
|
|
1303
|
+
function printProjectMove(
|
|
1304
|
+
plan: ProjectMovePlan,
|
|
1305
|
+
out: (line: string) => void,
|
|
1306
|
+
err: (line: string) => void,
|
|
1307
|
+
): void {
|
|
1308
|
+
for (const pod of plan.pods) {
|
|
1309
|
+
if (pod.artifact === "missing") continue; // its refusal below is the pod's line
|
|
1310
|
+
const artifact =
|
|
1311
|
+
pod.artifact === "present"
|
|
1312
|
+
? `artifacts at ${pod.to}`
|
|
1313
|
+
: `move artifacts from ${pod.from}`;
|
|
1314
|
+
out(`✓ relocate ${pod.id} ${plan.oldPath} → ${plan.newPath} (${artifact})`);
|
|
1315
|
+
}
|
|
1316
|
+
for (const listener of plan.listeners) {
|
|
1317
|
+
out(`✓ relocate ${listener.id} (template) ${plan.oldPath} → ${plan.newPath}`);
|
|
1318
|
+
}
|
|
1319
|
+
for (const project of plan.projects) {
|
|
1320
|
+
out(
|
|
1321
|
+
`✓ relocate capacity record ${plan.oldPath} → ${plan.newPath} (maxActive ${project.maxActive})`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
for (const refusal of plan.refusals) err(`✗ ${refusal.reason}`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Relocate records after the operator moved the tree; identity stays put (ADR 0028). */
|
|
1328
|
+
async function projectMoveCmd(
|
|
1329
|
+
words: string[],
|
|
1330
|
+
deps: CliDeps,
|
|
1331
|
+
out: (line: string) => void,
|
|
1332
|
+
err: (line: string) => void,
|
|
1333
|
+
): Promise<number> {
|
|
1334
|
+
const apply = words.includes("--apply");
|
|
1335
|
+
const moveArtifacts = words.includes("--move-artifacts");
|
|
1336
|
+
const positional = words.filter((word) => word !== "--apply" && word !== "--move-artifacts");
|
|
1337
|
+
if (
|
|
1338
|
+
positional.length !== 2 ||
|
|
1339
|
+
positional.some((word) => word.startsWith("--")) ||
|
|
1340
|
+
positional[0] === undefined ||
|
|
1341
|
+
positional[1] === undefined
|
|
1342
|
+
) {
|
|
1343
|
+
err(PROJECT_USAGE);
|
|
1344
|
+
return 2;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
1348
|
+
const oldPath = resolve(cwd, positional[0]);
|
|
1349
|
+
const newPath = resolve(cwd, positional[1]);
|
|
1350
|
+
if (oldPath === newPath) {
|
|
1351
|
+
err("old and new paths are the same; nothing to move");
|
|
1352
|
+
err(PROJECT_USAGE);
|
|
1353
|
+
return 2;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const plan = await planProjectMove(oldPath, newPath, { moveArtifacts });
|
|
1357
|
+
if (plan.matched === 0) {
|
|
1358
|
+
out(`no record is bound to ${oldPath}`);
|
|
1359
|
+
if (apply) out("nothing to do");
|
|
1360
|
+
return 0;
|
|
1361
|
+
}
|
|
1362
|
+
printProjectMove(plan, out, err);
|
|
1363
|
+
if (plan.refusals.length > 0) return 1;
|
|
1364
|
+
if (!apply) {
|
|
1365
|
+
out("run again with --apply to write");
|
|
1366
|
+
return 0;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
const result = await applyProjectMove(plan);
|
|
1371
|
+
if (result.files.length === 0) {
|
|
1372
|
+
out("nothing to do");
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
for (const file of result.files) {
|
|
1376
|
+
out(`wrote ${file.name}: ${file.changed} record${file.changed === 1 ? "" : "s"} changed`);
|
|
1377
|
+
}
|
|
1378
|
+
return 0;
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
err(`project move failed: ${(error as Error).message}`);
|
|
1381
|
+
return 1;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const ADAPTER_USAGE =
|
|
1386
|
+
"Usage: agents-pod adapter sync <name> [--dest <dir>] [--apply] [--force]";
|
|
1387
|
+
|
|
1388
|
+
/** One mark per file, so a dry run reads like a diff (ADR 0029). */
|
|
1389
|
+
const ADAPTER_MARKS: Record<AdapterFileState, string> = {
|
|
1390
|
+
same: "=",
|
|
1391
|
+
update: "↑",
|
|
1392
|
+
"missing-in-copy": "+",
|
|
1393
|
+
"local-edit": "!",
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
function printAdapterPlan(plan: AdapterSyncPlan, out: (line: string) => void): void {
|
|
1397
|
+
out(`adapter ${plan.name} ${plan.packageDir} → ${plan.dir} (package ${plan.packageVersion})`);
|
|
1398
|
+
for (const file of plan.files) {
|
|
1399
|
+
const kept = file.state === "local-edit" ? " (kept: a local edit; --force overwrites)" : "";
|
|
1400
|
+
out(` ${ADAPTER_MARKS[file.state]} ${file.name} ${file.state}${kept}`);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* The package ships, the operator copies, `doctor` reports drift (ADR 0029).
|
|
1406
|
+
* Dry run by default: nothing writes into a directory that holds a live
|
|
1407
|
+
* credential unless a human asked for it in this invocation.
|
|
1408
|
+
*/
|
|
1409
|
+
async function adapterCmd(
|
|
1410
|
+
words: string[],
|
|
1411
|
+
deps: CliDeps,
|
|
1412
|
+
out: (line: string) => void,
|
|
1413
|
+
err: (line: string) => void,
|
|
1414
|
+
): Promise<number> {
|
|
1415
|
+
const [sub, ...rest] = words;
|
|
1416
|
+
if (sub !== "sync") {
|
|
1417
|
+
err(`Unknown adapter command: ${sub ?? "(none)"}. Try: sync`);
|
|
1418
|
+
err(ADAPTER_USAGE);
|
|
1419
|
+
return 2;
|
|
1420
|
+
}
|
|
1421
|
+
const apply = rest.includes("--apply");
|
|
1422
|
+
const force = rest.includes("--force");
|
|
1423
|
+
const { flags, rest: positional } = parseFlags(
|
|
1424
|
+
rest.filter((word) => word !== "--apply" && word !== "--force"),
|
|
1425
|
+
["dest"],
|
|
1426
|
+
);
|
|
1427
|
+
const name = positional[0];
|
|
1428
|
+
if (positional.length !== 1 || name === undefined) {
|
|
1429
|
+
err(ADAPTER_USAGE);
|
|
1430
|
+
return 2;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
let plan: AdapterSyncPlan;
|
|
1434
|
+
try {
|
|
1435
|
+
plan = await planAdapterSync(name, {
|
|
1436
|
+
dest: flags.dest === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), flags.dest),
|
|
1437
|
+
});
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
err((error as Error).message);
|
|
1440
|
+
return 2;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
printAdapterPlan(plan, out);
|
|
1444
|
+
const pending = plan.files.filter((file) => file.state !== "same").length;
|
|
1445
|
+
if (pending === 0) {
|
|
1446
|
+
out(`already matches package ${plan.packageVersion}`);
|
|
1447
|
+
return 0;
|
|
1448
|
+
}
|
|
1449
|
+
if (!apply) {
|
|
1450
|
+
out("run again with --apply to write");
|
|
1451
|
+
return 0;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const result = await applyAdapterSync(plan, { force });
|
|
1455
|
+
if (result.copied.length > 0) out(`copied ${result.copied.join(", ")}`);
|
|
1456
|
+
if (result.kept.length > 0) out(`kept ${result.kept.join(", ")} (local edits; --force overwrites)`);
|
|
1457
|
+
out(`recorded package ${plan.packageVersion} in ${result.manifest}`);
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/** Per-project capacity and project-location record maintenance (ADR 0022, 0028). */
|
|
1289
1462
|
async function projectCmd(
|
|
1290
1463
|
words: string[],
|
|
1291
1464
|
deps: CliDeps,
|
|
@@ -1293,10 +1466,17 @@ async function projectCmd(
|
|
|
1293
1466
|
err: (line: string) => void,
|
|
1294
1467
|
): Promise<number> {
|
|
1295
1468
|
const [sub, ...rest] = words;
|
|
1469
|
+
if (sub === "move") return projectMoveCmd(rest, deps, out, err);
|
|
1470
|
+
if (sub !== "cap") {
|
|
1471
|
+
err(`Unknown project command: ${sub ?? "(none)"}. Try: cap | move`);
|
|
1472
|
+
err(PROJECT_USAGE);
|
|
1473
|
+
return 2;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1296
1476
|
const { flags, rest: positional } = parseFlags(rest, ["project"]);
|
|
1297
1477
|
const project = resolve(flags.project ?? deps.cwd ?? process.cwd());
|
|
1298
|
-
if (
|
|
1299
|
-
err(
|
|
1478
|
+
if (positional.length > 1) {
|
|
1479
|
+
err(PROJECT_USAGE);
|
|
1300
1480
|
return 2;
|
|
1301
1481
|
}
|
|
1302
1482
|
|
|
@@ -1928,7 +2108,7 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
1928
2108
|
out("");
|
|
1929
2109
|
out("start here agents-pod doctor what is missing on this machine, and the command that fixes it");
|
|
1930
2110
|
out("pods spawn | list | status <id> | logs <id> | wake <id> | cancel <id>");
|
|
1931
|
-
out("records agent | listener | source | deliver | project | reconcile");
|
|
2111
|
+
out("records agent | listener | source | deliver | adapter | project | reconcile");
|
|
1932
2112
|
return 0;
|
|
1933
2113
|
}
|
|
1934
2114
|
|
|
@@ -1955,6 +2135,8 @@ export async function main(argv: string[], deps: CliDeps = {}): Promise<number>
|
|
|
1955
2135
|
return sourceCmd(rest, deps, out, err);
|
|
1956
2136
|
case "deliver":
|
|
1957
2137
|
return deliverCmd(rest, deps, out, err);
|
|
2138
|
+
case "adapter":
|
|
2139
|
+
return adapterCmd(rest, deps, out, err);
|
|
1958
2140
|
case "project":
|
|
1959
2141
|
return projectCmd(rest, deps, out, err);
|
|
1960
2142
|
case "doctor":
|