@comment-io/cli 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 +76 -0
- package/bin/comment.js +24 -0
- package/docs/COMMENTFS-SYNC-USAGE.md +88 -0
- package/package.json +40 -0
- package/scripts/commentd.ts +398 -0
- package/scripts/commentfs-sync.ts +586 -0
- package/shared/comment-notifications.ts +388 -0
- package/shared/commentfs-sync.ts +1558 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Every
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Comment Docs
|
|
2
|
+
|
|
3
|
+
Collaborative markdown editor with provenance tracking, comments, suggestions, and an agent API. Powers [Comment.io](https://comment.io).
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- **Backend** (`cf/`) — Cloudflare Workers + Durable Objects + Yjs
|
|
8
|
+
- **Frontend** (`src/`) — React 19, Zustand, Milkdown, Tailwind CSS v4, Vite
|
|
9
|
+
|
|
10
|
+
## Local Development
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
make dev # CF Worker (:8787) + Vite editor (:3100) — background
|
|
14
|
+
make dev-stop # Stop dev servers
|
|
15
|
+
make logs # Tail structured logs (jq)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Tests
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd cf && npx vitest run # Backend test suite
|
|
22
|
+
npx vite build # Verify frontend builds
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Agent API
|
|
26
|
+
|
|
27
|
+
The canonical reference for the agent-facing REST API is served at `/llms.txt` (and `/docs/api` for the interactive spec). Starting points:
|
|
28
|
+
|
|
29
|
+
- `POST /docs` — create a document
|
|
30
|
+
- `GET /docs/:id` — read (markdown + marks)
|
|
31
|
+
- `PATCH /docs/:id` — edit via `{ old_string, new_string }` patches
|
|
32
|
+
- `POST /docs/:id/comments` — comment / suggest / reply
|
|
33
|
+
|
|
34
|
+
## CommentFS Local Sync
|
|
35
|
+
|
|
36
|
+
CommentFS can project selected Comment.io docs into read-only local markdown
|
|
37
|
+
files under `~/Comment Docs`.
|
|
38
|
+
|
|
39
|
+
Install the CLI:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g @comment-io/cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
comment sync login --api-key <usk_...>
|
|
47
|
+
comment sync
|
|
48
|
+
comment sync watch
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Enable **Sync locally** on a document first. Local files are not an edit path;
|
|
52
|
+
edit through the UI or API. See `docs/COMMENTFS-SYNC-USAGE.md`.
|
|
53
|
+
|
|
54
|
+
## Local Notification Daemon
|
|
55
|
+
|
|
56
|
+
Registered agents can receive @mention notifications through the local daemon:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
comment daemon install
|
|
60
|
+
comment daemon health
|
|
61
|
+
comment notifications wait --profile <handle> --timeout 30m
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The notification wait command leases work locally. Ack the delivered claim after
|
|
65
|
+
handling it, or release it so another worker can retry:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
comment notifications ack <claim-id>
|
|
69
|
+
comment notifications release <claim-id>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Docs
|
|
73
|
+
|
|
74
|
+
- `CLAUDE.md` — repo-level agent/developer instructions
|
|
75
|
+
- `docs/LOGGING.md` — structured logging guide
|
|
76
|
+
- `docs/ARCHITECTURE.md` — architecture notes
|
package/bin/comment.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const binDir = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const packageRoot = resolve(binDir, '..');
|
|
8
|
+
const tsxCli = resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
9
|
+
const syncCli = resolve(packageRoot, 'scripts', 'commentfs-sync.ts');
|
|
10
|
+
|
|
11
|
+
const child = spawnSync(process.execPath, [tsxCli, syncCli, ...process.argv.slice(2)], {
|
|
12
|
+
stdio: 'inherit',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (child.error) {
|
|
16
|
+
console.error(child.error.message);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (child.signal) {
|
|
21
|
+
process.kill(process.pid, child.signal);
|
|
22
|
+
} else {
|
|
23
|
+
process.exit(child.status ?? 0);
|
|
24
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# CommentFS Read-Only Local Sync
|
|
2
|
+
|
|
3
|
+
CommentFS projects selected Comment.io documents into local markdown files. The
|
|
4
|
+
online Comment.io document is canonical. Local markdown files are read-only
|
|
5
|
+
snapshots for search, context, indexing, and agent inspection.
|
|
6
|
+
|
|
7
|
+
Do not edit synced markdown files directly. Humans should edit through the
|
|
8
|
+
Comment.io UI. Agents should edit through the Comment.io API.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
The CommentFS UI is currently gated behind `VITE_ENABLE_COMMENTFS_UI=true` so it
|
|
13
|
+
can be enabled on staging without exposing the flow in production.
|
|
14
|
+
|
|
15
|
+
1. Open Comment.io settings and generate a CommentFS key.
|
|
16
|
+
2. Configure the local CLI:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
comment sync login --api-key <usk_...>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For staging or another deployment, include the base URL:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
comment sync login --api-key <usk_...> --base-url https://staging.example.com
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
3. Open a document's access panel and enable **Sync locally**.
|
|
29
|
+
4. Run one sync:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
comment sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
5. Keep projections updated:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
comment sync watch
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By default files are written under `~/Comment Docs`.
|
|
42
|
+
|
|
43
|
+
## Useful Commands
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
comment sync status
|
|
47
|
+
comment sync repair
|
|
48
|
+
comment sync explain <path>
|
|
49
|
+
comment sync recover <path>
|
|
50
|
+
comment sync watch --interval 10s --full-interval 5m
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`comment sync repair` restores read-only permissions on existing projections.
|
|
54
|
+
`comment sync explain` points a markdown or sidecar path back to its source
|
|
55
|
+
document and API docs. `comment sync recover` explains a preserved local edit
|
|
56
|
+
artifact.
|
|
57
|
+
|
|
58
|
+
## Sidecars
|
|
59
|
+
|
|
60
|
+
Each sync root has a `.comment/` folder. Important files include:
|
|
61
|
+
|
|
62
|
+
- `.comment/manifest.json`: local projection manifest.
|
|
63
|
+
- `.comment/docs/<slug>/status.json`: sync health, source URL, revision, sidecar
|
|
64
|
+
paths, and recovery metadata.
|
|
65
|
+
- `.comment/docs/<slug>/edit.md`: short edit instructions and API doc links.
|
|
66
|
+
- `.comment/docs/<slug>/authorship.json`: authorship/provenance metadata.
|
|
67
|
+
- `.comment/docs/<slug>/comments.json`: comment and suggestion metadata.
|
|
68
|
+
- `.comment/docs/<slug>/participants.json`: participant metadata.
|
|
69
|
+
- `.comment/recovery/*.local.md`: preserved local text from unsupported local
|
|
70
|
+
edits.
|
|
71
|
+
|
|
72
|
+
## Local Edits
|
|
73
|
+
|
|
74
|
+
If a local tool changes a synced markdown file, the next sync preserves that
|
|
75
|
+
local text under `.comment/recovery/` and restores the canonical Comment.io
|
|
76
|
+
version. It does not upload local markdown edits.
|
|
77
|
+
|
|
78
|
+
To apply an intended change, open the document in Comment.io or use the API docs
|
|
79
|
+
linked from the sidecar `api_docs_url`.
|
|
80
|
+
|
|
81
|
+
## Auth Notes
|
|
82
|
+
|
|
83
|
+
`COMMENT_IO_USER_API_KEY` and `usk_` keys are only for read-only projection sync.
|
|
84
|
+
They can poll selected docs and fetch projections. They cannot write documents.
|
|
85
|
+
|
|
86
|
+
Agent edits need an edit-capable Comment.io credential such as
|
|
87
|
+
`COMMENT_IO_AGENT_SECRET`, a registered agent credential, or an edit-capable
|
|
88
|
+
per-document token.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@comment-io/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Comment.io CLI and local notification daemon",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://comment.io",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+ssh://git@github.com/comment-io/comment.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/comment-io/comment/issues"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"comment": "bin/comment.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin/",
|
|
23
|
+
"scripts/commentfs-sync.ts",
|
|
24
|
+
"scripts/commentd.ts",
|
|
25
|
+
"shared/commentfs-sync.ts",
|
|
26
|
+
"shared/comment-notifications.ts",
|
|
27
|
+
"docs/COMMENTFS-SYNC-USAGE.md",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"prepack": "node ../../scripts/prepare-cli-package.mjs"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"tsx": "^4.21.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { rm, readFile } from 'node:fs/promises';
|
|
5
|
+
import { hostname, homedir, platform } from 'node:os';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_COMMENTFS_BASE_URL,
|
|
11
|
+
DEFAULT_SYNC_ROOT,
|
|
12
|
+
readCommentFsConfig,
|
|
13
|
+
syncConfiguredCommentDocsSettled,
|
|
14
|
+
syncRemoteSelectedCommentDocsSettled,
|
|
15
|
+
} from '../shared/commentfs-sync.js';
|
|
16
|
+
import {
|
|
17
|
+
appendLog,
|
|
18
|
+
buildQueueEnvelope,
|
|
19
|
+
daemonLogPath,
|
|
20
|
+
daemonPidPath,
|
|
21
|
+
daemonRequest,
|
|
22
|
+
daemonSocketPath,
|
|
23
|
+
ensurePrivateDir,
|
|
24
|
+
findClaimFile,
|
|
25
|
+
isProcessAlive,
|
|
26
|
+
loadAgentProfiles,
|
|
27
|
+
readPid,
|
|
28
|
+
removeClaimFile,
|
|
29
|
+
republishClaimFile,
|
|
30
|
+
sweepExpiredLocalClaims,
|
|
31
|
+
writePid,
|
|
32
|
+
writeQueueEnvelope,
|
|
33
|
+
type CommentAgentProfile,
|
|
34
|
+
type DaemonRequest,
|
|
35
|
+
type DaemonResponse,
|
|
36
|
+
type NotificationLeaseEnvelope,
|
|
37
|
+
type NotificationPayload,
|
|
38
|
+
} from '../shared/comment-notifications.js';
|
|
39
|
+
|
|
40
|
+
interface DaemonOptions {
|
|
41
|
+
rootDir?: string;
|
|
42
|
+
homeDir?: string;
|
|
43
|
+
intervalMs?: number;
|
|
44
|
+
fullIntervalMs?: number;
|
|
45
|
+
maxBackoffMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface NotificationProfileHealth {
|
|
49
|
+
handle: string;
|
|
50
|
+
connected: boolean;
|
|
51
|
+
lastConnectedAt: string | null;
|
|
52
|
+
lastMessageAt: string | null;
|
|
53
|
+
lastLeaseAt: string | null;
|
|
54
|
+
lastError: string | null;
|
|
55
|
+
reconnects: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface DaemonHealth {
|
|
59
|
+
pid: number;
|
|
60
|
+
started_at: string;
|
|
61
|
+
socket_path: string;
|
|
62
|
+
sync: {
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
last_attempt_at: string | null;
|
|
65
|
+
last_success_at: string | null;
|
|
66
|
+
last_error_at: string | null;
|
|
67
|
+
last_error: string | null;
|
|
68
|
+
consecutive_failures: number;
|
|
69
|
+
};
|
|
70
|
+
notifications: Record<string, NotificationProfileHealth>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const defaultOptions: Required<DaemonOptions> = {
|
|
74
|
+
rootDir: DEFAULT_SYNC_ROOT,
|
|
75
|
+
homeDir: homedir(),
|
|
76
|
+
intervalMs: 10_000,
|
|
77
|
+
fullIntervalMs: 5 * 60_000,
|
|
78
|
+
maxBackoffMs: 60_000,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export async function runCommentDaemon(options: DaemonOptions = {}): Promise<void> {
|
|
82
|
+
const opts = { ...defaultOptions, ...options };
|
|
83
|
+
const socketPath = daemonSocketPath(opts.homeDir);
|
|
84
|
+
const pidPath = daemonPidPath(opts.homeDir);
|
|
85
|
+
const logPath = daemonLogPath(new Date(), opts.homeDir);
|
|
86
|
+
const abort = new AbortController();
|
|
87
|
+
const startedAt = new Date().toISOString();
|
|
88
|
+
const profileHealth = new Map<string, NotificationProfileHealth>();
|
|
89
|
+
const health: DaemonHealth = {
|
|
90
|
+
pid: process.pid,
|
|
91
|
+
started_at: startedAt,
|
|
92
|
+
socket_path: socketPath,
|
|
93
|
+
sync: {
|
|
94
|
+
enabled: false,
|
|
95
|
+
last_attempt_at: null,
|
|
96
|
+
last_success_at: null,
|
|
97
|
+
last_error_at: null,
|
|
98
|
+
last_error: null,
|
|
99
|
+
consecutive_failures: 0,
|
|
100
|
+
},
|
|
101
|
+
notifications: {},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function log(level: 'info' | 'warn' | 'error', msg: string, data: Record<string, unknown> = {}): Promise<void> {
|
|
105
|
+
await appendLog(logPath, JSON.stringify({ ts: new Date().toISOString(), level, component: 'commentd', msg, data }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await ensurePrivateDir(dirname(pidPath));
|
|
109
|
+
const existingPid = await readPid(pidPath);
|
|
110
|
+
if (existingPid && await isProcessAlive(existingPid)) {
|
|
111
|
+
throw new Error(`comment daemon already running with pid ${existingPid}`);
|
|
112
|
+
}
|
|
113
|
+
await writePid(pidPath);
|
|
114
|
+
|
|
115
|
+
if (platform() !== 'win32' && existsSync(socketPath)) {
|
|
116
|
+
const resp = await daemonRequest({ op: 'health' }, opts.homeDir, 500);
|
|
117
|
+
if (resp.ok) throw new Error(`comment daemon already listening at ${socketPath}`);
|
|
118
|
+
await rm(socketPath, { force: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const server = net.createServer((socket) => {
|
|
122
|
+
let buffer = '';
|
|
123
|
+
socket.on('data', (chunk) => {
|
|
124
|
+
buffer += chunk.toString('utf-8');
|
|
125
|
+
const idx = buffer.indexOf('\n');
|
|
126
|
+
if (idx < 0) return;
|
|
127
|
+
const raw = buffer.slice(0, idx);
|
|
128
|
+
buffer = buffer.slice(idx + 1);
|
|
129
|
+
handleSocketRequest(raw)
|
|
130
|
+
.then((response) => socket.end(`${JSON.stringify(response)}\n`))
|
|
131
|
+
.catch((err) => socket.end(`${JSON.stringify({ ok: false, error: String(err), code: 'INTERNAL' })}\n`));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function handleSocketRequest(raw: string): Promise<DaemonResponse> {
|
|
136
|
+
let request: DaemonRequest;
|
|
137
|
+
try {
|
|
138
|
+
request = JSON.parse(raw) as DaemonRequest;
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, error: 'Invalid JSON', code: 'BAD_REQUEST' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (request.op === 'health') {
|
|
144
|
+
health.notifications = Object.fromEntries(profileHealth);
|
|
145
|
+
return { ok: true, op: 'health', health };
|
|
146
|
+
}
|
|
147
|
+
if (request.op === 'stop') {
|
|
148
|
+
abort.abort();
|
|
149
|
+
return { ok: true, op: 'stop' };
|
|
150
|
+
}
|
|
151
|
+
if (request.op === 'reload-profiles') {
|
|
152
|
+
await log('info', 'daemon.reload_profiles_requested');
|
|
153
|
+
return { ok: true, op: 'reload-profiles' };
|
|
154
|
+
}
|
|
155
|
+
if (request.op === 'ack' || request.op === 'release') {
|
|
156
|
+
if (!request.claim_id) return { ok: false, error: 'Missing claim_id', code: 'VALIDATION_ERROR' };
|
|
157
|
+
return proxyClaim(request.op, request.claim_id);
|
|
158
|
+
}
|
|
159
|
+
return { ok: false, error: `Unknown op: ${(request as { op?: string }).op}`, code: 'UNKNOWN_OP' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function proxyClaim(op: 'ack' | 'release', claimId: string): Promise<DaemonResponse> {
|
|
163
|
+
const found = await findClaimFile(claimId, opts.homeDir);
|
|
164
|
+
if (!found) return { ok: false, error: `Unknown local claim ${claimId}`, code: 'CLAIM_NOT_FOUND' };
|
|
165
|
+
const envelope = JSON.parse(await readFile(found.path, 'utf-8')) as NotificationLeaseEnvelope;
|
|
166
|
+
const profiles = await loadAgentProfiles({ homeDir: opts.homeDir, defaultBaseUrl: envelope.base_url });
|
|
167
|
+
const profile = profiles.find((item) => item.handle === envelope.profile);
|
|
168
|
+
if (!profile) return { ok: false, error: `Profile not configured: ${envelope.profile}`, code: 'PROFILE_NOT_FOUND' };
|
|
169
|
+
|
|
170
|
+
const url = `${profile.baseUrl}/agents/me/notifications/claim/${encodeURIComponent(claimId)}/${op}`;
|
|
171
|
+
const resp = await fetch(url, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { Authorization: `Bearer ${profile.agentSecret}` },
|
|
174
|
+
});
|
|
175
|
+
const body = await resp.text();
|
|
176
|
+
if (!resp.ok) {
|
|
177
|
+
await log('warn', `notification.${op}_failed`, { claim_id: claimId, status: resp.status, body });
|
|
178
|
+
return { ok: false, error: body || `${op} failed`, code: `SERVER_${resp.status}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (op === 'ack') await removeClaimFile(claimId, opts.homeDir);
|
|
182
|
+
else await republishClaimFile(claimId, opts.homeDir);
|
|
183
|
+
await log('info', `notification.${op}`, { claim_id: claimId, profile: envelope.profile });
|
|
184
|
+
return { ok: true, op, claim_id: claimId };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
188
|
+
server.once('error', reject);
|
|
189
|
+
server.listen(socketPath, () => resolvePromise());
|
|
190
|
+
});
|
|
191
|
+
if (platform() !== 'win32') {
|
|
192
|
+
await import('node:fs/promises').then(({ chmod }) => chmod(socketPath, 0o600)).catch(() => {});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await log('info', 'daemon.started', { pid: process.pid, socket_path: socketPath });
|
|
196
|
+
|
|
197
|
+
const loops = [
|
|
198
|
+
runSyncLoop(opts, health, abort.signal, log),
|
|
199
|
+
runNotificationLoops(opts, profileHealth, abort.signal, log),
|
|
200
|
+
runLocalSweepLoop(opts.homeDir, abort.signal, log),
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const stop = async () => {
|
|
204
|
+
abort.abort();
|
|
205
|
+
server.close();
|
|
206
|
+
if (platform() !== 'win32') await rm(socketPath, { force: true });
|
|
207
|
+
await rm(pidPath, { force: true });
|
|
208
|
+
await log('info', 'daemon.stopped');
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
process.once('SIGTERM', () => { void stop(); });
|
|
212
|
+
process.once('SIGINT', () => { void stop(); });
|
|
213
|
+
|
|
214
|
+
await Promise.race([
|
|
215
|
+
Promise.allSettled(loops),
|
|
216
|
+
new Promise<void>((resolvePromise) => abort.signal.addEventListener('abort', () => resolvePromise(), { once: true })),
|
|
217
|
+
]);
|
|
218
|
+
await stop();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function runSyncLoop(
|
|
222
|
+
opts: Required<DaemonOptions>,
|
|
223
|
+
health: DaemonHealth,
|
|
224
|
+
signal: AbortSignal,
|
|
225
|
+
log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
let lastFullSyncAt = 0;
|
|
228
|
+
while (!signal.aborted) {
|
|
229
|
+
const attemptedAt = new Date().toISOString();
|
|
230
|
+
health.sync.last_attempt_at = attemptedAt;
|
|
231
|
+
try {
|
|
232
|
+
const configured = await readCommentFsConfig(opts.homeDir).catch(() => null);
|
|
233
|
+
const userApiKey = process.env.COMMENT_IO_USER_API_KEY ?? configured?.userApiKey;
|
|
234
|
+
const baseUrl = (configured?.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, '');
|
|
235
|
+
health.sync.enabled = Boolean(userApiKey);
|
|
236
|
+
const full = Boolean(userApiKey && (lastFullSyncAt === 0 || Date.now() - lastFullSyncAt >= opts.fullIntervalMs));
|
|
237
|
+
const results = userApiKey
|
|
238
|
+
? await syncRemoteSelectedCommentDocsSettled({ rootDir: resolve(opts.rootDir), baseUrl, userApiKey, full, homeDir: opts.homeDir })
|
|
239
|
+
: await syncConfiguredCommentDocsSettled({ rootDir: resolve(opts.rootDir), homeDir: opts.homeDir });
|
|
240
|
+
if (full) lastFullSyncAt = Date.now();
|
|
241
|
+
const failures = results.filter((result) => !result.ok).length;
|
|
242
|
+
if (failures > 0) throw new Error(`${failures} CommentFS sync item(s) failed`);
|
|
243
|
+
health.sync.last_success_at = new Date().toISOString();
|
|
244
|
+
health.sync.last_error = null;
|
|
245
|
+
health.sync.last_error_at = null;
|
|
246
|
+
health.sync.consecutive_failures = 0;
|
|
247
|
+
await log('info', 'sync.poll_ok', { count: results.length, full });
|
|
248
|
+
await delay(opts.intervalMs, signal);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
health.sync.consecutive_failures += 1;
|
|
251
|
+
health.sync.last_error_at = new Date().toISOString();
|
|
252
|
+
health.sync.last_error = err instanceof Error ? err.message : String(err);
|
|
253
|
+
const backoff = Math.min(opts.maxBackoffMs, Math.max(opts.intervalMs, opts.intervalMs * 2 ** Math.min(health.sync.consecutive_failures - 1, 6)));
|
|
254
|
+
await log('warn', 'sync.poll_failed', { error: health.sync.last_error, retry_ms: backoff });
|
|
255
|
+
await delay(backoff, signal);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function runNotificationLoops(
|
|
261
|
+
opts: Required<DaemonOptions>,
|
|
262
|
+
profileHealth: Map<string, NotificationProfileHealth>,
|
|
263
|
+
signal: AbortSignal,
|
|
264
|
+
log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const profiles = await loadAgentProfiles({ homeDir: opts.homeDir });
|
|
267
|
+
if (profiles.length === 0) {
|
|
268
|
+
await log('info', 'notifications.no_profiles');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await Promise.allSettled(profiles.map((profile) => runNotificationProfile(profile, opts.homeDir, profileHealth, signal, log)));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function runNotificationProfile(
|
|
275
|
+
profile: CommentAgentProfile,
|
|
276
|
+
homeDir: string,
|
|
277
|
+
profileHealth: Map<string, NotificationProfileHealth>,
|
|
278
|
+
signal: AbortSignal,
|
|
279
|
+
log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
let reconnects = 0;
|
|
282
|
+
const health: NotificationProfileHealth = {
|
|
283
|
+
handle: profile.handle,
|
|
284
|
+
connected: false,
|
|
285
|
+
lastConnectedAt: null,
|
|
286
|
+
lastMessageAt: null,
|
|
287
|
+
lastLeaseAt: null,
|
|
288
|
+
lastError: null,
|
|
289
|
+
reconnects,
|
|
290
|
+
};
|
|
291
|
+
profileHealth.set(profile.handle, health);
|
|
292
|
+
|
|
293
|
+
while (!signal.aborted) {
|
|
294
|
+
try {
|
|
295
|
+
health.connected = true;
|
|
296
|
+
health.lastConnectedAt = new Date().toISOString();
|
|
297
|
+
health.lastError = null;
|
|
298
|
+
const lease = await waitForServerNotification(profile, signal);
|
|
299
|
+
health.lastMessageAt = new Date().toISOString();
|
|
300
|
+
if (lease) {
|
|
301
|
+
const envelope = buildQueueEnvelope({
|
|
302
|
+
profile: profile.handle,
|
|
303
|
+
baseUrl: profile.baseUrl,
|
|
304
|
+
claimId: lease.claim_id,
|
|
305
|
+
claimedAt: lease.claimed_at,
|
|
306
|
+
leaseExpiresAt: lease.lease_expires_at,
|
|
307
|
+
notification: lease.notification,
|
|
308
|
+
});
|
|
309
|
+
await writeQueueEnvelope(envelope, homeDir);
|
|
310
|
+
health.lastLeaseAt = new Date().toISOString();
|
|
311
|
+
await log('info', 'notifications.queued', { profile: profile.handle, claim_id: lease.claim_id, notification_id: lease.notification.id });
|
|
312
|
+
}
|
|
313
|
+
reconnects = 0;
|
|
314
|
+
health.reconnects = reconnects;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
health.lastError = err instanceof Error ? err.message : String(err);
|
|
317
|
+
await log('warn', 'notifications.wait_failed', { profile: profile.handle, error: health.lastError });
|
|
318
|
+
reconnects += 1;
|
|
319
|
+
health.reconnects = reconnects;
|
|
320
|
+
await delay(Math.min(60_000, 1000 * 2 ** Math.min(reconnects, 6)), signal);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
health.connected = false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function waitForServerNotification(
|
|
327
|
+
profile: CommentAgentProfile,
|
|
328
|
+
signal: AbortSignal,
|
|
329
|
+
): Promise<{
|
|
330
|
+
claim_id: string;
|
|
331
|
+
claimed_at: string;
|
|
332
|
+
lease_expires_at: string;
|
|
333
|
+
notification: NotificationPayload;
|
|
334
|
+
} | null> {
|
|
335
|
+
const resp = await fetch(`${profile.baseUrl}/agents/me/notifications/wait?timeout=55&lease=600`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
signal,
|
|
338
|
+
headers: {
|
|
339
|
+
Authorization: `Bearer ${profile.agentSecret}`,
|
|
340
|
+
'Content-Type': 'application/json',
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
if (resp.status === 204) return null;
|
|
344
|
+
if (!resp.ok) throw new Error(`wait failed with ${resp.status}`);
|
|
345
|
+
return resp.json() as Promise<{
|
|
346
|
+
claim_id: string;
|
|
347
|
+
claimed_at: string;
|
|
348
|
+
lease_expires_at: string;
|
|
349
|
+
notification: NotificationPayload;
|
|
350
|
+
}>;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function runLocalSweepLoop(
|
|
354
|
+
homeDir: string,
|
|
355
|
+
signal: AbortSignal,
|
|
356
|
+
log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
|
|
357
|
+
): Promise<void> {
|
|
358
|
+
while (!signal.aborted) {
|
|
359
|
+
const swept = await sweepExpiredLocalClaims(homeDir).catch(() => 0);
|
|
360
|
+
if (swept > 0) await log('info', 'notifications.local_claims_swept', { swept });
|
|
361
|
+
await delay(30_000, signal);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
366
|
+
return new Promise((resolvePromise) => {
|
|
367
|
+
if (signal.aborted) {
|
|
368
|
+
resolvePromise();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const timer = setTimeout(resolvePromise, ms);
|
|
372
|
+
signal.addEventListener('abort', () => {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
resolvePromise();
|
|
375
|
+
}, { once: true });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export async function startDetachedDaemon(args: string[] = []): Promise<{ pid: number | undefined }> {
|
|
380
|
+
const script = fileURLToPath(import.meta.url);
|
|
381
|
+
const packageRoot = resolve(dirname(script), '..');
|
|
382
|
+
const tsxCli = resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
383
|
+
const child = spawn(process.execPath, [tsxCli, script, 'run', ...args], {
|
|
384
|
+
detached: true,
|
|
385
|
+
stdio: 'ignore',
|
|
386
|
+
});
|
|
387
|
+
child.unref();
|
|
388
|
+
return { pid: child.pid };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
392
|
+
if (process.argv[2] === 'run') {
|
|
393
|
+
runCommentDaemon().catch((err) => {
|
|
394
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
395
|
+
process.exit(1);
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|