@pellux/goodvibes-daemon 1.28.19 → 1.28.21
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 +119 -63
- package/README.md +24 -19
- package/bin/launcher-support.js +5 -5
- package/package.json +6 -4
- package/scripts/postinstall.js +8 -8
- package/src/cli/command-catalog.ts +22 -22
- package/src/cli/completion.ts +4 -4
- package/src/cli/help.ts +5 -5
- package/src/cli/index.ts +3 -3
- package/src/cli/parser.ts +2 -2
- package/src/cli/surface-catalog.ts +1 -1
- package/src/cli/types.ts +2 -2
- package/src/cluster/daemon-ws-call.ts +5 -5
- package/src/cluster/raw-reply-route.ts +5 -5
- package/src/config/checkpoint-settings.ts +7 -7
- package/src/config/config-key-guard.ts +22 -0
- package/src/config/run-daemon-config-migration.ts +3 -3
- package/src/config/secret-config.ts +7 -7
- package/src/config/surface.ts +3 -3
- package/src/core/pairing-banner.ts +5 -5
- package/src/daemon/cli.ts +45 -43
- package/src/daemon/config-command.ts +15 -15
- package/src/daemon/handlers/context.ts +1 -1
- package/src/daemon/handlers/contracts.ts +19 -4
- package/src/daemon/handlers/credentials.ts +1 -1
- package/src/daemon/handlers/drafts/draft-store.ts +3 -3
- package/src/daemon/handlers/drafts/register.ts +4 -4
- package/src/daemon/handlers/inbox/aggregator.ts +8 -8
- package/src/daemon/handlers/inbox/cursor-store.ts +10 -10
- package/src/daemon/handlers/inbox/index.ts +7 -7
- package/src/daemon/handlers/inbox/mapping.ts +2 -2
- package/src/daemon/handlers/inbox/poller.ts +5 -5
- package/src/daemon/handlers/inbox/provider-adapter.ts +8 -8
- package/src/daemon/handlers/inbox/providers/discord.ts +6 -6
- package/src/daemon/handlers/inbox/providers/email.ts +3 -3
- package/src/daemon/handlers/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/handlers/inbox/providers/slack.ts +4 -4
- package/src/daemon/handlers/index.ts +18 -8
- package/src/daemon/handlers/payments/address-store.ts +54 -0
- package/src/daemon/handlers/payments/budget-store.ts +356 -0
- package/src/daemon/handlers/payments/card-store.ts +486 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
- package/src/daemon/handlers/payments/index.ts +38 -0
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/purchase-ledger.ts +108 -0
- package/src/daemon/handlers/payments/register.ts +518 -0
- package/src/daemon/handlers/register.ts +3 -3
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +9 -1
- package/src/daemon/handlers/remote/backends/process-runner.ts +1 -1
- package/src/daemon/handlers/remote/backends/ssh.ts +9 -1
- package/src/daemon/handlers/remote/backends/types.ts +2 -2
- package/src/daemon/handlers/remote/dispatcher.ts +3 -3
- package/src/daemon/handlers/remote/index.ts +1 -1
- package/src/daemon/handlers/remote/peer-registry.ts +62 -13
- package/src/daemon/handlers/routing/inbox-bridge.ts +5 -5
- package/src/daemon/handlers/routing/index.ts +1 -1
- package/src/daemon/handlers/routing/route-store.ts +1 -1
- package/src/daemon/handlers/routing/routing-resolver.ts +3 -3
- package/src/daemon/handlers/sqlite-store.ts +9 -9
- package/src/daemon/handlers/triage/index.ts +1 -1
- package/src/daemon/handlers/triage/integration.ts +3 -3
- package/src/daemon/handlers/triage/pipeline.ts +2 -2
- package/src/daemon/handlers/triage/scorer.ts +2 -2
- package/src/daemon/handlers/triage/tagger/discord.ts +3 -3
- package/src/daemon/handlers/triage/tagger/imap.ts +7 -7
- package/src/daemon/handlers/triage/tagger/index.ts +1 -1
- package/src/daemon/handlers/triage/tagger/shared.ts +3 -3
- package/src/daemon/handlers/triage/tagger/slack.ts +1 -1
- package/src/daemon/handlers/triage/types.ts +2 -2
- package/src/daemon/lifecycle.ts +5 -5
- package/src/daemon/local-daemon-state.ts +7 -7
- package/src/daemon/pair-command.ts +14 -14
- package/src/daemon/provision-wake-model.ts +5 -5
- package/src/daemon/send/channels.ts +7 -7
- package/src/daemon/send/command.ts +11 -11
- package/src/daemon/send/composition.ts +5 -5
- package/src/daemon/send/failure-text.ts +6 -6
- package/src/daemon/send/inert-text.ts +18 -18
- package/src/daemon/send/stdin.ts +3 -3
- package/src/daemon/service-commands.ts +32 -32
- package/src/daemon/sessions-command.ts +7 -7
- package/src/daemon/status-command.ts +22 -22
- package/src/daemon/webui-command.ts +14 -14
- package/src/runtime/boot-tasks.ts +1 -1
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/cluster-composition.ts +9 -9
- package/src/runtime/cluster-group-composition.ts +7 -7
- package/src/runtime/conversation-rewind-port.ts +8 -8
- package/src/runtime/credential-composition.ts +2 -2
- package/src/runtime/daemon-handler-composition.ts +61 -4
- package/src/runtime/device-posture-composition.ts +10 -10
- package/src/runtime/disposal-wiring.ts +8 -8
- package/src/runtime/fleet-needs-input-push.ts +4 -4
- package/src/runtime/fleet-services.ts +1 -1
- package/src/runtime/hosted-session-composition.ts +13 -13
- package/src/runtime/index.ts +1 -1
- package/src/runtime/knowledge-services.ts +2 -2
- package/src/runtime/legacy-daemon-migration.ts +43 -43
- package/src/runtime/legacy-daemon-reconcile.ts +30 -30
- package/src/runtime/mail-composition.ts +6 -6
- package/src/runtime/notification-dispatch.ts +7 -7
- package/src/runtime/payments-composition.ts +187 -0
- package/src/runtime/plugin-composition.ts +7 -7
- package/src/runtime/runtime-services-types.ts +9 -9
- package/src/runtime/services.ts +41 -32
- package/src/runtime/trigger-services.ts +1 -1
- package/src/runtime/trust/checkpoint-eligibility.ts +5 -5
- package/src/runtime/trust/trust-gated-approvals.ts +9 -9
- package/src/runtime/update-check.ts +4 -4
- package/src/runtime/workspace-checkpointing.ts +6 -6
- package/src/testing/daemon-fixture.ts +11 -11
- package/src/testing/hosted-session-failures.ts +4 -4
- package/src/version.ts +2 -2
|
@@ -27,7 +27,7 @@ export interface SshBackendConfig {
|
|
|
27
27
|
sshHost: string;
|
|
28
28
|
sshPort?: number;
|
|
29
29
|
sshUser: string;
|
|
30
|
-
/** goodvibes://secrets/ reference to the private key
|
|
30
|
+
/** goodvibes://secrets/ reference to the private key, never the raw key. */
|
|
31
31
|
identityRef: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -71,7 +71,7 @@ export interface PeerRegistrationInput {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
// Validation helpers
|
|
74
|
+
// Validation helpers, backendConfig must hold ONLY secret refs for any
|
|
75
75
|
// credential-bearing field. Raw secrets are rejected outright.
|
|
76
76
|
// ---------------------------------------------------------------------------
|
|
77
77
|
|
|
@@ -111,9 +111,9 @@ function requireSecretRef(value: unknown, field: string): string {
|
|
|
111
111
|
function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
112
112
|
// A dockerHost is accepted in exactly two shapes, mirroring how docker.ts
|
|
113
113
|
// resolves it (docker.ts: `startsWith('goodvibes://') ? resolveRef(...) : raw`):
|
|
114
|
-
// 1. A goodvibes://secrets/ reference
|
|
114
|
+
// 1. A goodvibes://secrets/ reference, resolved from the credential store.
|
|
115
115
|
// 2. A credential-free local/plain address (unix:// socket, or a bare
|
|
116
|
-
// tcp/host with no embedded userinfo)
|
|
116
|
+
// tcp/host with no embedded userinfo), used verbatim.
|
|
117
117
|
// Enforcement here must match that resolution so no credential-bearing or
|
|
118
118
|
// unresolvable value slips through to docker.ts.
|
|
119
119
|
if (value === undefined) return;
|
|
@@ -123,7 +123,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
|
123
123
|
|
|
124
124
|
// A `goodvibes://` value that is NOT a well-formed secret ref would be handed
|
|
125
125
|
// to credentials.resolveRef() and fail opaquely (REMOTE_BACKEND_CREDENTIAL_MISSING)
|
|
126
|
-
|
|
126
|
+
//, or, worse, a near-miss could be treated as a literal host. Reject it at
|
|
127
127
|
// registration so the misconfiguration surfaces immediately.
|
|
128
128
|
if (isMalformedGoodVibesSecretReferenceValue(value)) {
|
|
129
129
|
throw new PeerRegistryValidationError(
|
|
@@ -132,7 +132,7 @@ function assertDockerHostSafe(value: string | undefined, field: string): void {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Embedded userinfo credentials (e.g. tcp://user:pass@host) must never be
|
|
135
|
-
// stored raw
|
|
135
|
+
// stored raw, docker.ts would pass them verbatim as DOCKER_HOST.
|
|
136
136
|
if (value.includes('@')) {
|
|
137
137
|
throw new PeerRegistryValidationError(
|
|
138
138
|
`Field '${field}' appears to embed credentials; pass a goodvibes://secrets/ reference instead.`,
|
|
@@ -226,7 +226,7 @@ export function normalizeBackendConfig(
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
// ---------------------------------------------------------------------------
|
|
229
|
-
// Peer registry
|
|
229
|
+
// Peer registry, persisted via HandlerSqliteStore (peer-registry.sqlite)
|
|
230
230
|
// ---------------------------------------------------------------------------
|
|
231
231
|
|
|
232
232
|
const PEER_REGISTRY_FILE = 'peer-registry.sqlite';
|
|
@@ -247,21 +247,62 @@ interface PeerRow {
|
|
|
247
247
|
backendConfig: string;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
// Typed ReadonlySet<string> (not ReadonlySet<BackendKind>) so isBackendKind
|
|
251
|
+
// below can call .has() with a plain string and let the function's own `value
|
|
252
|
+
// is BackendKind` signature do the narrowing, instead of casting the set.
|
|
253
|
+
const VALID_BACKEND_KINDS: ReadonlySet<string> = new Set<BackendKind>([
|
|
251
254
|
'docker',
|
|
252
255
|
'ssh',
|
|
253
256
|
'cloud-terminal',
|
|
254
257
|
'local-process',
|
|
255
258
|
]);
|
|
256
259
|
|
|
260
|
+
/** True when `value` is one of the four known backend kinds; narrows to BackendKind. */
|
|
261
|
+
function isBackendKind(value: string): value is BackendKind {
|
|
262
|
+
return VALID_BACKEND_KINDS.has(value);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
266
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Turn a stored row back into a typed PeerRecord. backendConfig is written
|
|
271
|
+
* only by register() through normalizeBackendConfig(), but a row read back
|
|
272
|
+
* from disk is untrusted the same way a fresh registration's raw input is: a
|
|
273
|
+
* hand-edited database file, a row left over from a schema this file no
|
|
274
|
+
* longer writes, or on-disk corruption can all put something here that was
|
|
275
|
+
* never actually normalized. Re-running it through normalizeBackendConfig
|
|
276
|
+
* catches that at the read boundary instead of handing a malformed object to
|
|
277
|
+
* a caller that assumes register()'s guarantees already hold.
|
|
278
|
+
*/
|
|
257
279
|
function rowToRecord(row: PeerRow): PeerRecord {
|
|
258
|
-
|
|
259
|
-
|
|
280
|
+
if (!isBackendKind(row.backendKind)) {
|
|
281
|
+
throw new PeerRegistryValidationError(
|
|
282
|
+
`Peer '${row.peerId}' has an unknown backendKind '${row.backendKind}'; the row is corrupt or from an unsupported version.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const backendKind = row.backendKind;
|
|
286
|
+
|
|
287
|
+
let raw: unknown;
|
|
288
|
+
try {
|
|
289
|
+
raw = JSON.parse(row.backendConfig);
|
|
290
|
+
} catch {
|
|
291
|
+
throw new PeerRegistryValidationError(
|
|
292
|
+
`Peer '${row.peerId}' has a backendConfig that is not valid JSON; the row is corrupt.`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (!isPlainObject(raw)) {
|
|
296
|
+
throw new PeerRegistryValidationError(
|
|
297
|
+
`Peer '${row.peerId}' has a backendConfig that is not an object; the row is corrupt.`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
260
301
|
return {
|
|
261
302
|
peerId: row.peerId,
|
|
262
303
|
displayName: row.displayName,
|
|
263
304
|
backendKind,
|
|
264
|
-
backendConfig:
|
|
305
|
+
backendConfig: normalizeBackendConfig(backendKind, raw),
|
|
265
306
|
};
|
|
266
307
|
}
|
|
267
308
|
|
|
@@ -335,10 +376,18 @@ export class PeerRegistry {
|
|
|
335
376
|
return rows.map(rowToRecord);
|
|
336
377
|
}
|
|
337
378
|
|
|
338
|
-
/**
|
|
379
|
+
/**
|
|
380
|
+
* Remove a peer. Returns true when a row was deleted. Existence is checked
|
|
381
|
+
* without row validation so a corrupt row (which get() rejects) can still
|
|
382
|
+
* be removed.
|
|
383
|
+
*/
|
|
339
384
|
async remove(peerId: string): Promise<boolean> {
|
|
340
385
|
this.requireInit();
|
|
341
|
-
const
|
|
386
|
+
const row = this.store.all<{ peerId: string }>(
|
|
387
|
+
'SELECT peerId FROM peers WHERE peerId = ?',
|
|
388
|
+
[peerId],
|
|
389
|
+
);
|
|
390
|
+
const existed = row.length > 0;
|
|
342
391
|
if (existed) {
|
|
343
392
|
this.store.run('DELETE FROM peers WHERE peerId = ?', [peerId]);
|
|
344
393
|
await this.store.save();
|
|
@@ -9,22 +9,22 @@
|
|
|
9
9
|
// `getProfileForChannel(surfaceKind, routeId?)` applies the canonical
|
|
10
10
|
// resolution order:
|
|
11
11
|
//
|
|
12
|
-
// 1. exact
|
|
13
|
-
// 2. surface
|
|
14
|
-
// 3. wildcard
|
|
12
|
+
// 1. exact , surfaceKind AND routeId both match
|
|
13
|
+
// 2. surface , surfaceKind matches, route has no routeId
|
|
14
|
+
// 3. wildcard , a route with surfaceKind === 'any' (no routeId)
|
|
15
15
|
//
|
|
16
16
|
// This module bridges the two shapes. The bridge maps an inbound item's
|
|
17
17
|
// `provider` to the routing `surfaceKind` and forwards an optional `routeId`
|
|
18
18
|
// refinement; `fromDigest`/`kind` are NOT used as routing keys (the routing
|
|
19
19
|
// store is keyed by surfaceKind + optional routeId only, never by sender digest
|
|
20
|
-
// or message kind
|
|
20
|
+
// or message kind, see RoutingChannelRoute). When no binding matches the
|
|
21
21
|
// resolver returns null and the bridge yields `undefined`, so the adapter falls
|
|
22
22
|
// back to leaving the item unrouted (offline/default behaviour intact). The
|
|
23
23
|
// bridge NEVER throws: the underlying resolver is a pure in-memory lookup.
|
|
24
24
|
//
|
|
25
25
|
// The inbox surface is a separate follow-up module, so this bridge declares the
|
|
26
26
|
// minimal `RouteResolver` seam locally rather than importing a sibling that the
|
|
27
|
-
// routing slice does not own
|
|
27
|
+
// routing slice does not own, keeping the routing handler layer free of any
|
|
28
28
|
// cross-surface import edge.
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Channel routing surface
|
|
2
|
+
// Channel routing surface, HANDLERS for the SDK-defined methods
|
|
3
3
|
// - channels.routing.list (read-only)
|
|
4
4
|
// - channels.routing.assign (admin, confirmation-gated)
|
|
5
5
|
// - channels.routing.delete (admin, dangerous, confirmation-gated)
|
|
@@ -96,7 +96,7 @@ const SCHEMA: string[] = [
|
|
|
96
96
|
updatedAt TEXT NOT NULL
|
|
97
97
|
)`,
|
|
98
98
|
`CREATE INDEX IF NOT EXISTS idx_routes_surface_route ON routes (surfaceKind, routeId)`,
|
|
99
|
-
// Enforce one assignment per channel
|
|
99
|
+
// Enforce one assignment per channel, the upsert path relies on this.
|
|
100
100
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_routes_channel ON routes (channelId)`,
|
|
101
101
|
];
|
|
102
102
|
|
|
@@ -9,9 +9,9 @@ export const WILDCARD_SURFACE = 'any';
|
|
|
9
9
|
* order as the agent-side `getProfileForChannel()` so offline and online
|
|
10
10
|
* routing produce identical results:
|
|
11
11
|
*
|
|
12
|
-
* 1. exact match
|
|
13
|
-
* 2. surface-only
|
|
14
|
-
* 3. wildcard
|
|
12
|
+
* 1. exact match , surfaceKind AND routeId both match
|
|
13
|
+
* 2. surface-only , surfaceKind matches, route has no routeId
|
|
14
|
+
* 3. wildcard , a route with surfaceKind === 'any' (no routeId)
|
|
15
15
|
*
|
|
16
16
|
* Returns the matching profileId, or `null` when nothing matches.
|
|
17
17
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="@pellux/goodvibes-sdk/sql-js" />
|
|
2
2
|
// `sql.js` ships no types. The declaration is SDK-owned and reaches here
|
|
3
|
-
// through that reference
|
|
3
|
+
// through that reference, there is no local copy to keep in step.
|
|
4
4
|
|
|
5
5
|
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
@@ -23,7 +23,7 @@ export interface SqliteStoreOptions {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The 16 bytes every SQLite file begins with ("SQLite format 3" + NUL). A file
|
|
26
|
-
* that does not start with these is not a database, whatever its extension
|
|
26
|
+
* that does not start with these is not a database, whatever its extension,
|
|
27
27
|
* which is exactly what a crash mid-create, a zero-fill, or a partially
|
|
28
28
|
* restored backup leaves behind.
|
|
29
29
|
*/
|
|
@@ -93,7 +93,7 @@ export class HandlerSqliteStore {
|
|
|
93
93
|
/**
|
|
94
94
|
* Move a database file that cannot be opened aside instead of deleting it, so
|
|
95
95
|
* an operator still has something to salvage, and return the quarantine path.
|
|
96
|
-
* Returns null when the move itself failed
|
|
96
|
+
* Returns null when the move itself failed, in which case the caller starts
|
|
97
97
|
* fresh in memory and the bad file is left exactly where it was rather than
|
|
98
98
|
* being overwritten on the next save.
|
|
99
99
|
*/
|
|
@@ -110,8 +110,8 @@ export class HandlerSqliteStore {
|
|
|
110
110
|
/**
|
|
111
111
|
* Bound the quarantine directory: drop `.corrupt-*` copies of THIS store that
|
|
112
112
|
* are past the TTL or beyond the keep-newest count. Best-effort and
|
|
113
|
-
* idempotent
|
|
114
|
-
* error
|
|
113
|
+
* idempotent, a file another process already removed is success, not an
|
|
114
|
+
* error, so two daemons opening the same store at once cannot fight.
|
|
115
115
|
* Returns how many were reclaimed.
|
|
116
116
|
*/
|
|
117
117
|
private reapCorruptQuarantines(nowMs: number): number {
|
|
@@ -158,7 +158,7 @@ export class HandlerSqliteStore {
|
|
|
158
158
|
|
|
159
159
|
// Validate the file by its CONTENT, not by existsSync. `save()` writes
|
|
160
160
|
// through a pid-and-timestamp temp file and an atomic rename, so it cannot
|
|
161
|
-
// itself leave a half-written database
|
|
161
|
+
// itself leave a half-written database, but a zero-filled file recovered
|
|
162
162
|
// by a filesystem, a truncated restore, or a copy interrupted by something
|
|
163
163
|
// outside this process all produce a path that exists and holds no usable
|
|
164
164
|
// database. Handing those bytes to `new SQL.Database(...)` throws out of
|
|
@@ -200,7 +200,7 @@ export class HandlerSqliteStore {
|
|
|
200
200
|
}
|
|
201
201
|
} catch (error) {
|
|
202
202
|
// Header present but the body does not hold up: a truncated or damaged
|
|
203
|
-
// database. Same treatment
|
|
203
|
+
// database. Same treatment, set aside, disclose, start clean.
|
|
204
204
|
quarantineReason = `database would not open: ${error instanceof Error ? error.message : String(error)}`;
|
|
205
205
|
this.db = null;
|
|
206
206
|
quarantined = this.quarantineUnreadable();
|
|
@@ -215,7 +215,7 @@ export class HandlerSqliteStore {
|
|
|
215
215
|
// Disclosure: starting a store from scratch is data loss from the user's
|
|
216
216
|
// point of view, so it is never allowed to happen quietly.
|
|
217
217
|
if (quarantineReason) {
|
|
218
|
-
logger.warn('daemon store could not be opened
|
|
218
|
+
logger.warn('daemon store could not be opened; starting a fresh one', {
|
|
219
219
|
store: this.options.fileName,
|
|
220
220
|
path: this.resolvedPath,
|
|
221
221
|
reason: quarantineReason,
|
|
@@ -266,7 +266,7 @@ export class HandlerSqliteStore {
|
|
|
266
266
|
* saves of the same store in the SAME millisecond produced the same temp path
|
|
267
267
|
* before it did: both wrote it, the first rename moved it away, and the
|
|
268
268
|
* second failed with ENOENT on a file it had just written. That is not a
|
|
269
|
-
* hypothetical race
|
|
269
|
+
* hypothetical race, the inbox poller flushes once per provider and polls
|
|
270
270
|
* every provider concurrently, so it hit on an ordinary two-provider startup,
|
|
271
271
|
* and the failure surfaced as a provider reporting a filesystem error for its
|
|
272
272
|
* feed. Every store on this base shares the hazard, so the counter lives here.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// runtime composition root calls. It DECORATES the inbox surface's
|
|
6
6
|
// `channels.inbox.list` handler (overlaying persisted triage metadata) and
|
|
7
7
|
// returns the poller-facing pipeline + tagger. inbox.triage.* are NOT published
|
|
8
|
-
// catalog methods
|
|
8
|
+
// catalog methods, this surface registers no triage method id.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
export {
|
|
@@ -12,12 +12,12 @@
|
|
|
12
12
|
// overlaid with the persisted triageScore/triageTags via
|
|
13
13
|
// enrichItemsWithTriage(). Every other registration passes straight
|
|
14
14
|
// through to the real catalog. The inbox descriptor/schema/id is never
|
|
15
|
-
// re-authored
|
|
15
|
+
// re-authored, only its handler is wrapped.
|
|
16
16
|
// 2. It exposes the triage pipeline + tagger (`runInboxTriage`, `tagger`) for
|
|
17
17
|
// the daemon-internal poller, which scores items and persists them to the
|
|
18
18
|
// co-located inbox-triage.sqlite store the decorator reads from.
|
|
19
19
|
// 3. inbox.triage.* are intentionally NOT registered on the catalog (they are
|
|
20
|
-
// a daemon-internal pipeline, not published methods)
|
|
20
|
+
// a daemon-internal pipeline, not published methods), so this module
|
|
21
21
|
// makes ZERO catalog.register call for any triage id.
|
|
22
22
|
//
|
|
23
23
|
// Reads in the decorator are best-effort and degrade to the raw item when no
|
|
@@ -51,7 +51,7 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
53
|
* Canonical id of the inbox list method whose handler we decorate. This is the
|
|
54
|
-
* SDK's published id
|
|
54
|
+
* SDK's published id, referenced as a plain string for matching during
|
|
55
55
|
* registration; no descriptor or schema is authored here.
|
|
56
56
|
*/
|
|
57
57
|
export const INBOX_LIST_METHOD_ID = 'channels.inbox.list';
|
|
@@ -57,7 +57,7 @@ export interface RunInboxTriageOptions {
|
|
|
57
57
|
scorer?: TriageScorerOptions;
|
|
58
58
|
/** Inject a store (tests). When omitted, a triage store is opened/closed. */
|
|
59
59
|
store?: HandlerSqliteStore;
|
|
60
|
-
/** When true, do not persist
|
|
60
|
+
/** When true, do not persist, only compute (used by inbox.triage.list). */
|
|
61
61
|
dryRun?: boolean;
|
|
62
62
|
/** Clock injection for deterministic updatedAt in tests. */
|
|
63
63
|
now?: () => Date;
|
|
@@ -254,7 +254,7 @@ export function enrichItemsWithTriage<T extends { id: string }>(
|
|
|
254
254
|
items: readonly T[],
|
|
255
255
|
): Array<T & TriageOverlay> {
|
|
256
256
|
if (items.length === 0) return [];
|
|
257
|
-
// Single batched read (`WHERE id IN (...)`) instead of one SELECT per item
|
|
257
|
+
// Single batched read (`WHERE id IN (...)`) instead of one SELECT per item,
|
|
258
258
|
// this is a hot read path (every channels.inbox.list call), so the N+1 is
|
|
259
259
|
// collapsed to one query keyed by id.
|
|
260
260
|
const byId = readTriageMetadataBatch(
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Pure, dependency-free heuristic + naive-Bayes-style spam/priority scoring
|
|
5
5
|
// over an InboundChannelItem's textual surface (subject + snippet). No I/O,
|
|
6
|
-
// no credentials, no network
|
|
6
|
+
// no credentials, no network, safe to run on every polled item.
|
|
7
7
|
//
|
|
8
8
|
// The score is a normalized 0..1 confidence in the assigned label. The label
|
|
9
9
|
// is one of 'spam' | 'priority' | 'normal'. Scoring is deterministic so the
|
|
@@ -247,7 +247,7 @@ export function scoreInboundItem(
|
|
|
247
247
|
// A trailing question mark suggests an awaited answer.
|
|
248
248
|
if (/\?\s*$/.test(raw)) priorityLL += 0.5;
|
|
249
249
|
|
|
250
|
-
// Spam strongly suppresses priority
|
|
250
|
+
// Spam strongly suppresses priority, promotional text is rarely urgent.
|
|
251
251
|
priorityLL -= spam * 2.5;
|
|
252
252
|
|
|
253
253
|
const priority = clamp01(sigmoid(priorityLL));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Triage tagger
|
|
2
|
+
// Triage tagger, Discord provider.
|
|
3
3
|
//
|
|
4
4
|
// Two paths, in priority order:
|
|
5
5
|
// 1. REAL thread tags: when the item targets a forum/media-channel thread and
|
|
6
6
|
// a forum-tag mapping resolves >=1 tag id, PATCH the thread's applied_tags
|
|
7
|
-
// (read-then-merge
|
|
7
|
+
// (read-then-merge, never blind overwrite). Exact fidelity to the
|
|
8
8
|
// contract's "Discord thread tags".
|
|
9
9
|
// 2. Reaction analog: Discord has no arbitrary per-message tags, so otherwise
|
|
10
10
|
// add a unicode reaction (PUT .../reactions/{emoji}/@me).
|
|
@@ -114,7 +114,7 @@ async function applyDiscordThreadTags(
|
|
|
114
114
|
// DATA-LOSS GUARD: we PATCH the thread's full applied_tags array, so we must
|
|
115
115
|
// first read the EXISTING tags and merge. If that read fails (network error
|
|
116
116
|
// OR a non-ok HTTP status), we have no idea what tags are currently on the
|
|
117
|
-
// thread
|
|
117
|
+
// thread, PATCHing with only our new ids would silently destroy whatever
|
|
118
118
|
// forum tags were already applied. Abort instead of overwriting.
|
|
119
119
|
const existing = await fetchDiscordAppliedTags(url, token, fetchImpl, ctx);
|
|
120
120
|
if (existing === null) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Triage tagger
|
|
2
|
+
// Triage tagger, IMAP provider.
|
|
3
3
|
//
|
|
4
4
|
// Applies a triage label as an IMAP keyword flag (STORE +FLAGS) over a minimal
|
|
5
5
|
// IMAP4rev1-over-TLS client. Credentials are resolved per-apply from the daemon
|
|
@@ -63,7 +63,7 @@ export type ImapConnect = (opts: {
|
|
|
63
63
|
servername: string;
|
|
64
64
|
}) => ImapSocketLike;
|
|
65
65
|
|
|
66
|
-
/** Production connector
|
|
66
|
+
/** Production connector, a real IMAP4rev1-over-TLS socket via node:tls. */
|
|
67
67
|
export const tlsImapConnect: ImapConnect = (opts) =>
|
|
68
68
|
tlsConnect(opts, () => {
|
|
69
69
|
/* greeting handled in the data pump */
|
|
@@ -128,7 +128,7 @@ export class ImapStoreError extends Error {
|
|
|
128
128
|
* in a value destined for an IMAP command line. CR/LF are the dangerous ones:
|
|
129
129
|
* an unescaped CRLF would terminate the current command and let an attacker
|
|
130
130
|
* inject a second IMAP command (CRLF injection). IMAP's quoted-string syntax
|
|
131
|
-
* has no escape for these control chars
|
|
131
|
+
* has no escape for these control chars, backslash only escapes `\` and `"`,
|
|
132
132
|
* so the only safe handling is to refuse the value outright.
|
|
133
133
|
*/
|
|
134
134
|
function assertImapSafe(value: string, field: string): void {
|
|
@@ -155,8 +155,8 @@ function quoteImap(value: string): string {
|
|
|
155
155
|
// A concrete UID sequence-set: one or more comma-separated single UIDs or
|
|
156
156
|
// numeric ranges (`12`, `3:9`, `1,4,7:9`). RFC 3501 also permits `*` (the
|
|
157
157
|
// largest UID in the mailbox) and ranges to `*`, but a wildcard would let a
|
|
158
|
-
// single value like `1:*` apply the flag to the ENTIRE mailbox
|
|
159
|
-
// intent when tagging one triaged item
|
|
158
|
+
// single value like `1:*` apply the flag to the ENTIRE mailbox, never the
|
|
159
|
+
// intent when tagging one triaged item, so `*` is rejected outright.
|
|
160
160
|
const IMAP_UID_SET = /^[0-9]+(:[0-9]+)?(,[0-9]+(:[0-9]+)?)*$/;
|
|
161
161
|
|
|
162
162
|
// An IMAP flag: an optional leading `\` (system flag, e.g. `\Seen`) followed by
|
|
@@ -187,7 +187,7 @@ function assertImapUid(value: string): void {
|
|
|
187
187
|
* UNQUOTED into the `+FLAGS (...)` list. assertImapSafe only blocks control
|
|
188
188
|
* chars; a flag containing a space (e.g. `\Seen Junk`) would still pass that
|
|
189
189
|
* guard and inject a second flag atom. This is the boundary check that
|
|
190
|
-
* prevents it
|
|
190
|
+
* prevents it, independent of any upstream normalizer.
|
|
191
191
|
*/
|
|
192
192
|
function assertImapFlag(value: string): void {
|
|
193
193
|
if (!IMAP_FLAG.test(value)) {
|
|
@@ -313,7 +313,7 @@ export function imapStoreFlagOverTls(
|
|
|
313
313
|
finish();
|
|
314
314
|
}
|
|
315
315
|
} else {
|
|
316
|
-
// NO/BAD: deterministic protocol rejection
|
|
316
|
+
// NO/BAD: deterministic protocol rejection, do not retry.
|
|
317
317
|
current.reject(new ImapStoreError(`IMAP command failed: ${status}`, false));
|
|
318
318
|
}
|
|
319
319
|
};
|
|
@@ -159,7 +159,7 @@ export function createTriageTagger(
|
|
|
159
159
|
return { ...base, reason: 'autotag-disabled' };
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
// Provider-side mutation is effectful
|
|
162
|
+
// Provider-side mutation is effectful, require explicit confirmation.
|
|
163
163
|
if (request.confirm !== true || request.explicitUserRequest !== true) {
|
|
164
164
|
throw new HandlerError(
|
|
165
165
|
'Provider-side triage tagging requires explicit user confirmation.',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Triage tagger
|
|
2
|
+
// Triage tagger, shared types and provider-agnostic helpers.
|
|
3
3
|
//
|
|
4
4
|
// Provider config shapes, the apply request/result contract, and the tag
|
|
5
5
|
// normalization helpers used by the IMAP/Slack/Discord modules. No I/O here.
|
|
@@ -17,7 +17,7 @@ export interface TaggerProviderConfig {
|
|
|
17
17
|
* optional forum-tag mapping. When `forumTagIds` maps a GoodVibes triage tag
|
|
18
18
|
* (e.g. 'GoodVibes/Spam') to a forum tag SNOWFLAKE id, items that target a
|
|
19
19
|
* forum/media-channel thread get that REAL thread tag applied (PATCH
|
|
20
|
-
* applied_tags)
|
|
20
|
+
* applied_tags), exact fidelity to the contract's "Discord thread tags".
|
|
21
21
|
* Without a mapping (or for non-thread messages) tagging degrades to a
|
|
22
22
|
* unicode reaction analog.
|
|
23
23
|
*/
|
|
@@ -29,7 +29,7 @@ export interface ApplyTagsRequest {
|
|
|
29
29
|
/** Provider-side tags to apply. Defaults to [labelToTag(label)] when omitted. */
|
|
30
30
|
tags?: readonly string[];
|
|
31
31
|
label?: TriageLabel;
|
|
32
|
-
/** Must be true
|
|
32
|
+
/** Must be true, provider-side mutation requires explicit confirmation. */
|
|
33
33
|
confirm?: boolean;
|
|
34
34
|
/** Mirror of the operator invocation context flag. */
|
|
35
35
|
explicitUserRequest?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Triage tagger
|
|
2
|
+
// Triage tagger, Slack provider.
|
|
3
3
|
//
|
|
4
4
|
// Applies a triage label as a Slack message reaction (reactions.add). The bot
|
|
5
5
|
// token is resolved per-apply from the daemon credential store and is never
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// and the triage pipeline scores. It is NOT an SDK catalog contract: the
|
|
6
6
|
// published `channels.inbox.list` output schema (CHANNEL_INBOX_ITEM_SCHEMA) is
|
|
7
7
|
// owned by the SDK and never re-declared here. This is the internal poller
|
|
8
|
-
// item per the handoff doc
|
|
8
|
+
// item per the handoff doc, it carries `fromDigest` (never a raw sender id),
|
|
9
9
|
// `subjectPreview`/`bodyPreview` (PII-stripped, length-bounded) and an opaque
|
|
10
10
|
// `metadata` bag the tagger reads provider targeting from (imapUid, channelId,
|
|
11
11
|
// Slack ts, Discord messageId/threadId).
|
|
@@ -31,7 +31,7 @@ export interface InboundChannelItem {
|
|
|
31
31
|
/** Handoff-facing provider id ("slack" | "discord" | "email" | ...). */
|
|
32
32
|
readonly provider?: string;
|
|
33
33
|
readonly kind?: 'dm' | 'thread' | 'mention' | 'reaction';
|
|
34
|
-
/** SHA-256 first-N of sender external id
|
|
34
|
+
/** SHA-256 first-N of sender external id, NEVER a raw identifier. */
|
|
35
35
|
readonly fromDigest?: string;
|
|
36
36
|
/** Conversation id (Slack/Discord channel, IMAP mailbox-scoped). */
|
|
37
37
|
readonly conversationId?: string;
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lifecycle.ts
|
|
2
|
+
* lifecycle.ts, resolve THIS binary's update-artifact identity for the SDK
|
|
3
3
|
* DaemonServer facade's auto-update lifecycle.
|
|
4
4
|
*
|
|
5
5
|
* The SDK facade now runs the entire self-update loop itself when handed a
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
* version against the release tags (no longer the version-blind sdk-package
|
|
8
8
|
* comparison that used to restart-loop), swaps only at an idle moment, keeps
|
|
9
9
|
* the outgoing file at `<path>.previous`, and leaves a receipt in the store it
|
|
10
|
-
* serves on /status. When the artifact is ABSENT, updates are host-managed
|
|
10
|
+
* serves on /status. When the artifact is ABSENT, updates are host-managed,
|
|
11
11
|
* the facade runs no loop (the safe embedded default).
|
|
12
12
|
*
|
|
13
13
|
* The one guard the facade does NOT apply is install-kind: it must never swap a
|
|
14
14
|
* dev `bun run daemon` interpreter or a bun-global package install. This helper
|
|
15
|
-
* is that guard
|
|
15
|
+
* is that guard, it hands the facade an artifact ONLY for a compiled binary
|
|
16
16
|
* install, so a dev run resolves to `undefined` (host-managed, no loop) and
|
|
17
17
|
* only a real self-contained binary self-updates.
|
|
18
18
|
*/
|
|
@@ -23,13 +23,13 @@ import { detectInstallKind } from '../runtime/update-check.ts';
|
|
|
23
23
|
export interface ResolveDaemonUpdateArtifactOptions {
|
|
24
24
|
/** The executable to identify; defaults to process.execPath. */
|
|
25
25
|
readonly execPath?: string;
|
|
26
|
-
/** Injectable so tests pin a fixture version
|
|
26
|
+
/** Injectable so tests pin a fixture version, never the live build VERSION. */
|
|
27
27
|
readonly version?: string;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* The update-artifact identity to hand the DaemonServer facade, or `undefined`
|
|
32
|
-
* for a non-binary install (dev/source or bun-global package)
|
|
32
|
+
* for a non-binary install (dev/source or bun-global package), in which case
|
|
33
33
|
* the facade keeps updates host-managed and runs no swap loop.
|
|
34
34
|
*/
|
|
35
35
|
export function resolveDaemonUpdateArtifact(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* local-daemon-state.ts
|
|
2
|
+
* local-daemon-state.ts, the two files a daemon writes about ITSELF.
|
|
3
3
|
*
|
|
4
4
|
* `status` and `update` want to report things no control-plane verb answers:
|
|
5
5
|
* how long the daemon has been up, whether the last start followed a crash,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* <control-plane config dir>/control-plane/daemon-receipts.json
|
|
12
12
|
*
|
|
13
13
|
* They are READ here and never written, and the receipts are never marked
|
|
14
|
-
* delivered
|
|
14
|
+
* delivered, `/status?receipts=consume` hands each receipt to the first
|
|
15
15
|
* consuming reader exactly once, and a status command that quietly consumed
|
|
16
16
|
* them would take them away from the surface they were written for.
|
|
17
17
|
*
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* The SDK owns the writers (`platform/daemon/lifecycle-marker.ts`,
|
|
23
23
|
* `platform/daemon/receipts.ts`) and its readers are not exported from the
|
|
24
24
|
* published package this repository pins, so the readers below are this
|
|
25
|
-
* repository's own
|
|
25
|
+
* repository's own, bounded and content-validated the same way, and no more
|
|
26
26
|
* trusting of the file than the SDK is. The shared-piece lane can re-point them
|
|
27
27
|
* at the SDK's own readers once those are exported.
|
|
28
28
|
*/
|
|
@@ -42,7 +42,7 @@ export interface DaemonLifecycleMarker {
|
|
|
42
42
|
readonly pid: number | undefined;
|
|
43
43
|
readonly failedStarts: number;
|
|
44
44
|
readonly version: string | undefined;
|
|
45
|
-
/** The version an automatic rollback moved AWAY from
|
|
45
|
+
/** The version an automatic rollback moved AWAY from, the build that crash looped. */
|
|
46
46
|
readonly rejectedVersion: string | undefined;
|
|
47
47
|
/** When an automatic rollback last restored the kept previous binary. */
|
|
48
48
|
readonly autoRollbackAt: number | undefined;
|
|
@@ -164,7 +164,7 @@ export function readDaemonReceipts(
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
export interface LocalDaemonState {
|
|
167
|
-
/** False when the caller asked about another machine
|
|
167
|
+
/** False when the caller asked about another machine, nothing below was read. */
|
|
168
168
|
readonly available: boolean;
|
|
169
169
|
/** Why it is unavailable, when it is. */
|
|
170
170
|
readonly unavailableReason: string | undefined;
|
|
@@ -195,7 +195,7 @@ export function describeLocalDaemonState(input: DescribeLocalDaemonStateInput):
|
|
|
195
195
|
return {
|
|
196
196
|
available: false,
|
|
197
197
|
unavailableReason:
|
|
198
|
-
'the uptime, update receipts and rollback state are read from files on the daemon\'s own host
|
|
198
|
+
'the uptime, update receipts and rollback state are read from files on the daemon\'s own host; '
|
|
199
199
|
+ 'run this command on that machine to see them',
|
|
200
200
|
marker: null,
|
|
201
201
|
receipts: [],
|
|
@@ -219,7 +219,7 @@ export function describeLocalDaemonState(input: DescribeLocalDaemonStateInput):
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
/** `3d 4h`, `4h 12m`, `12m 3s`, `9s
|
|
222
|
+
/** `3d 4h`, `4h 12m`, `12m 3s`, `9s`, two units, never more. */
|
|
223
223
|
export function formatDuration(milliseconds: number): string {
|
|
224
224
|
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
225
225
|
const days = Math.floor(totalSeconds / 86_400);
|