@ours.network/fleet 0.11.1 → 0.13.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.
Files changed (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import type { ResolvedRoleLoop } from './config.js';
2
+ import { type ScheduledLoopsFile } from './state.js';
3
+ import { RoleTurnArbiter } from '../session/arbiter.js';
4
+ export interface LoopManagerDeps {
5
+ now(): number;
6
+ setTimer(callback: () => void, ms: number): unknown;
7
+ clearTimer(timer: unknown): void;
8
+ log(line: string): void;
9
+ }
10
+ export interface LoopActionResult {
11
+ state: 'started' | 'skipped_busy' | 'disabled' | 'unavailable';
12
+ runId?: string;
13
+ }
14
+ export interface ScheduledLoopManagerHandle {
15
+ start(): void;
16
+ stop(): Promise<void>;
17
+ status(): ScheduledLoopsFile;
18
+ runNow(name: string): Promise<LoopActionResult>;
19
+ disable(name: string): LoopActionResult;
20
+ enable(name: string): LoopActionResult;
21
+ reconcile(definitions: ResolvedRoleLoop[]): void;
22
+ }
23
+ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle {
24
+ private readonly role;
25
+ private readonly arbiter;
26
+ private readonly deps;
27
+ private readonly definitions;
28
+ private readonly store;
29
+ private timer?;
30
+ private stopping;
31
+ constructor(role: string, definitions: ResolvedRoleLoop[], stateDir: string, arbiter: RoleTurnArbiter, deps: LoopManagerDeps);
32
+ start(): void;
33
+ stop(): Promise<void>;
34
+ status(): ScheduledLoopsFile;
35
+ runNow(name: string): Promise<LoopActionResult>;
36
+ disable(name: string): LoopActionResult;
37
+ enable(name: string): LoopActionResult;
38
+ reconcile(definitions: ResolvedRoleLoop[]): void;
39
+ /** Public fake-clock seam; timer callbacks call the same transition. */
40
+ poll(): Promise<void>;
41
+ private attempt;
42
+ private finish;
43
+ private advance;
44
+ private skipMissed;
45
+ private skipRestartMisses;
46
+ private schedule;
47
+ private envelope;
48
+ }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ScheduledLoopStateStore, deterministicJitter, increment, } from './state.js';
3
+ export class ScheduledLoopManager {
4
+ role;
5
+ arbiter;
6
+ deps;
7
+ definitions = new Map();
8
+ store;
9
+ timer;
10
+ stopping = false;
11
+ constructor(role, definitions, stateDir, arbiter, deps) {
12
+ this.role = role;
13
+ this.arbiter = arbiter;
14
+ this.deps = deps;
15
+ for (const definition of definitions)
16
+ this.definitions.set(definition.name, definition);
17
+ this.store = new ScheduledLoopStateStore(stateDir, role, definitions, deps.now(), deps.log);
18
+ }
19
+ start() {
20
+ if (!this.store.fresh)
21
+ this.skipRestartMisses();
22
+ this.schedule();
23
+ }
24
+ async stop() {
25
+ this.stopping = true;
26
+ this.arbiter.stopScheduledAdmission();
27
+ if (this.timer !== undefined)
28
+ this.deps.clearTimer(this.timer);
29
+ this.timer = undefined;
30
+ this.store.state.clock.lastWallMs = this.deps.now();
31
+ this.store.persist();
32
+ }
33
+ status() { return structuredClone(this.store.state); }
34
+ async runNow(name) {
35
+ const definition = this.definitions.get(name);
36
+ const state = this.store.state.loops[name];
37
+ if (!definition || !state || this.stopping)
38
+ return { state: 'unavailable' };
39
+ if (!definition.enabled || state.operatorDisabled)
40
+ return { state: 'disabled' };
41
+ return this.attempt(definition, state, this.deps.now());
42
+ }
43
+ disable(name) {
44
+ const state = this.store.state.loops[name];
45
+ if (!state)
46
+ return { state: 'unavailable' };
47
+ state.operatorDisabled = true;
48
+ state.lastOutcome = 'operator_disabled';
49
+ this.store.persist();
50
+ this.schedule();
51
+ return { state: 'disabled' };
52
+ }
53
+ enable(name) {
54
+ const definition = this.definitions.get(name);
55
+ const state = this.store.state.loops[name];
56
+ if (!definition || !state)
57
+ return { state: 'unavailable' };
58
+ if (!definition.enabled)
59
+ return { state: 'disabled' };
60
+ state.operatorDisabled = false;
61
+ state.lastOutcome = 'operator_enabled';
62
+ this.store.persist();
63
+ this.schedule();
64
+ return { state: 'started' };
65
+ }
66
+ reconcile(definitions) {
67
+ if (this.stopping)
68
+ return;
69
+ this.definitions.clear();
70
+ for (const definition of definitions)
71
+ this.definitions.set(definition.name, definition);
72
+ this.store.reconcile(definitions, this.deps.now());
73
+ this.schedule();
74
+ }
75
+ /** Public fake-clock seam; timer callbacks call the same transition. */
76
+ async poll() {
77
+ if (this.stopping)
78
+ return;
79
+ const now = this.deps.now();
80
+ if (now < this.store.state.clock.lastWallMs - 5 * 60_000) {
81
+ this.store.state.health = 'degraded';
82
+ this.store.state.anomaly = 'clock_regression';
83
+ this.store.state.clock.lastWallMs = now;
84
+ this.store.persist();
85
+ this.deps.log(`[${this.role}] scheduled loops paused after backward clock jump`);
86
+ this.schedule();
87
+ return;
88
+ }
89
+ const due = [...this.definitions.values()].filter(definition => {
90
+ const state = this.store.state.loops[definition.name];
91
+ return definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now;
92
+ }).sort((a, b) => a.name.localeCompare(b.name));
93
+ for (const definition of due) {
94
+ const state = this.store.state.loops[definition.name];
95
+ if (now >= Date.parse(state.nextDueAt) + definition.intervalMs) {
96
+ this.skipMissed(definition, state, now);
97
+ continue;
98
+ }
99
+ const scheduledAt = Date.parse(state.nextScheduledAt);
100
+ this.advance(definition, state);
101
+ this.store.persist();
102
+ await this.attempt(definition, state, scheduledAt);
103
+ }
104
+ this.store.state.clock.lastWallMs = now;
105
+ this.store.persist();
106
+ this.schedule();
107
+ }
108
+ async attempt(definition, state, scheduledAt) {
109
+ const runId = `sl_${randomUUID()}`;
110
+ const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
111
+ const prompt = this.envelope(definition, runId, scheduledAt);
112
+ let claimed = false;
113
+ const result = await this.arbiter.tryScheduled(prompt, origin, () => {
114
+ claimed = true;
115
+ state.activeRunId = runId;
116
+ state.lastRunId = runId;
117
+ state.lastStartedAt = new Date(this.deps.now()).toISOString();
118
+ state.lastOutcome = 'running';
119
+ state.lastError = null;
120
+ state.counts.started = increment(state.counts.started);
121
+ this.store.persist();
122
+ });
123
+ if (result.state === 'skipped_busy') {
124
+ state.counts.skipped = increment(state.counts.skipped);
125
+ state.counts.skippedBusy = increment(state.counts.skippedBusy);
126
+ state.lastOutcome = 'skipped_busy';
127
+ state.lastFinishedAt = new Date(this.deps.now()).toISOString();
128
+ this.store.persist();
129
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_busy at ${new Date(scheduledAt).toISOString()}`);
130
+ return { state: 'skipped_busy' };
131
+ }
132
+ if (result.state === 'unavailable') {
133
+ state.activeRunId = null;
134
+ state.counts.failed = increment(state.counts.failed);
135
+ state.lastOutcome = claimed ? 'queue_failed' : 'unavailable';
136
+ state.lastFinishedAt = new Date(this.deps.now()).toISOString();
137
+ state.lastError = { kind: claimed ? 'queue_failed' : 'unavailable', at: state.lastFinishedAt };
138
+ this.store.persist();
139
+ this.deps.log(`[${this.role}] loop ${definition.name} ${state.lastOutcome} run=${runId.slice(0, 11)}`);
140
+ return { state: 'unavailable', runId };
141
+ }
142
+ this.deps.log(`[${this.role}] loop ${definition.name} started run=${runId.slice(0, 11)} scheduled=${new Date(scheduledAt).toISOString()}`);
143
+ void result.queued.completion.then(turn => this.finish(definition, state, runId, turn));
144
+ return { state: 'started', runId };
145
+ }
146
+ finish(definition, state, runId, result) {
147
+ if (state.activeRunId !== runId)
148
+ return;
149
+ state.activeRunId = null;
150
+ state.lastFinishedAt = new Date(this.deps.now()).toISOString();
151
+ state.lastCancellationSource = result.cancellationSource ?? null;
152
+ if (result.outcome === 'completed') {
153
+ state.counts.completed = increment(state.counts.completed);
154
+ state.lastOutcome = 'completed';
155
+ }
156
+ else if (result.outcome === 'cancelled') {
157
+ state.counts.cancelled = increment(state.counts.cancelled);
158
+ state.lastOutcome = `cancelled${result.cancellationSource ? `(${result.cancellationSource})` : ''}`;
159
+ }
160
+ else {
161
+ state.counts.failed = increment(state.counts.failed);
162
+ state.lastOutcome = result.outcome;
163
+ state.lastError = { kind: result.outcome, at: state.lastFinishedAt };
164
+ }
165
+ this.store.persist();
166
+ const duration = Date.parse(state.lastFinishedAt) - Date.parse(state.lastStartedAt);
167
+ this.deps.log(`[${this.role}] loop ${definition.name} finished run=${runId.slice(0, 11)} outcome=${state.lastOutcome} duration=${duration}ms`);
168
+ }
169
+ advance(definition, state) {
170
+ const current = Date.parse(state.nextScheduledAt);
171
+ state.lastScheduledAt = new Date(current).toISOString();
172
+ const next = current + definition.intervalMs;
173
+ state.nextScheduledAt = new Date(next).toISOString();
174
+ state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
175
+ }
176
+ skipMissed(definition, state, now) {
177
+ let missed = 0;
178
+ while (Date.parse(state.nextDueAt) <= now) {
179
+ this.advance(definition, state);
180
+ missed++;
181
+ }
182
+ state.counts.skipped = increment(state.counts.skipped, missed);
183
+ state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
184
+ state.lastOutcome = 'skipped_missed';
185
+ state.lastFinishedAt = new Date(now).toISOString();
186
+ this.store.persist();
187
+ this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
188
+ }
189
+ skipRestartMisses() {
190
+ const now = this.deps.now();
191
+ for (const definition of this.definitions.values()) {
192
+ const state = this.store.state.loops[definition.name];
193
+ if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
194
+ this.skipMissed(definition, state, now);
195
+ }
196
+ }
197
+ schedule() {
198
+ if (this.timer !== undefined)
199
+ this.deps.clearTimer(this.timer);
200
+ this.timer = undefined;
201
+ if (this.stopping)
202
+ return;
203
+ const due = [...this.definitions.values()].filter(definition => {
204
+ const state = this.store.state.loops[definition.name];
205
+ return definition.enabled && !state.operatorDisabled;
206
+ }).map(definition => Date.parse(this.store.state.loops[definition.name].nextDueAt));
207
+ if (!due.length)
208
+ return;
209
+ const delay = Math.max(0, Math.min(...due) - this.deps.now());
210
+ this.timer = this.deps.setTimer(() => {
211
+ void this.poll().catch(error => {
212
+ this.store.state.health = 'failed';
213
+ this.store.state.anomaly = 'manager_task_failed';
214
+ try {
215
+ this.store.persist();
216
+ }
217
+ catch { }
218
+ this.deps.log(`[${this.role}] scheduled loop manager failed: ${error?.name ?? 'Error'}`);
219
+ });
220
+ }, Math.min(delay, 60_000));
221
+ }
222
+ envelope(definition, runId, scheduledAt) {
223
+ return [
224
+ '[fleet-loop]',
225
+ `loop: ${definition.name}`,
226
+ `run: ${runId}`,
227
+ `scheduled_at: ${new Date(scheduledAt).toISOString()}`,
228
+ 'origin: local-trusted-config',
229
+ '',
230
+ 'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
231
+ 'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
232
+ 'configured policy and an existing authenticated proactive-report route authorize a material report.',
233
+ '',
234
+ definition.prompt,
235
+ ].join('\n');
236
+ }
237
+ }
@@ -0,0 +1,54 @@
1
+ import type { ResolvedRoleLoop } from './config.js';
2
+ export interface LoopCounts {
3
+ started: number;
4
+ completed: number;
5
+ failed: number;
6
+ cancelled: number;
7
+ skipped: number;
8
+ skippedBusy: number;
9
+ skippedMissed: number;
10
+ }
11
+ export interface LoopRuntimeState {
12
+ definitionHash: string;
13
+ promptHash: string;
14
+ enabled: boolean;
15
+ operatorDisabled: boolean;
16
+ nextScheduledAt: string;
17
+ nextDueAt: string;
18
+ lastScheduledAt: string | null;
19
+ lastStartedAt: string | null;
20
+ lastFinishedAt: string | null;
21
+ lastOutcome: string | null;
22
+ lastCancellationSource: string | null;
23
+ lastRunId: string | null;
24
+ activeRunId: string | null;
25
+ counts: LoopCounts;
26
+ lastError: {
27
+ kind: string;
28
+ at: string;
29
+ } | null;
30
+ }
31
+ export interface ScheduledLoopsFile {
32
+ version: 1;
33
+ role: string;
34
+ generation: string;
35
+ clock: {
36
+ lastWallMs: number;
37
+ };
38
+ health: 'healthy' | 'degraded' | 'failed';
39
+ anomaly: string | null;
40
+ loops: Record<string, LoopRuntimeState>;
41
+ }
42
+ export declare const increment: (value: number, amount?: number) => number;
43
+ export declare function deterministicJitter(role: string, loop: string, nominalMs: number, maximumMs: number): number;
44
+ export declare function scheduledLoopsPath(stateDir: string): string;
45
+ export declare function readScheduledLoops(stateDir: string): ScheduledLoopsFile | undefined;
46
+ export declare class ScheduledLoopStateStore {
47
+ private readonly log;
48
+ readonly path: string;
49
+ readonly fresh: boolean;
50
+ state: ScheduledLoopsFile;
51
+ constructor(stateDir: string, role: string, definitions: ResolvedRoleLoop[], now: number, log: (line: string) => void);
52
+ reconcile(definitions: ResolvedRoleLoop[], now: number, recoverActive?: boolean): void;
53
+ persist(): void;
54
+ }
@@ -0,0 +1,148 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, renameSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ const zeroCounts = () => ({
6
+ started: 0, completed: 0, failed: 0, cancelled: 0,
7
+ skipped: 0, skippedBusy: 0, skippedMissed: 0,
8
+ });
9
+ export const increment = (value, amount = 1) => Math.min(Number.MAX_SAFE_INTEGER, value + Math.max(0, amount));
10
+ export function deterministicJitter(role, loop, nominalMs, maximumMs) {
11
+ if (maximumMs <= 0)
12
+ return 0;
13
+ const digest = createHash('sha256').update(`${role}\0${loop}\0${nominalMs}`).digest();
14
+ const value = digest.readUIntBE(0, 6);
15
+ return value % (maximumMs + 1);
16
+ }
17
+ export function scheduledLoopsPath(stateDir) {
18
+ return join(stateDir, '.scheduled-loops.json');
19
+ }
20
+ export function readScheduledLoops(stateDir) {
21
+ try {
22
+ const path = scheduledLoopsPath(stateDir);
23
+ if (!safeStateFile(path))
24
+ return undefined;
25
+ const value = JSON.parse(readFileSync(path, 'utf8'));
26
+ return validFile(value) ? value : undefined;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export class ScheduledLoopStateStore {
33
+ log;
34
+ path;
35
+ fresh;
36
+ state;
37
+ constructor(stateDir, role, definitions, now, log) {
38
+ this.log = log;
39
+ this.path = scheduledLoopsPath(stateDir);
40
+ let restored;
41
+ let corrupt = false;
42
+ if (existsSync(this.path)) {
43
+ try {
44
+ if (!safeStateFile(this.path))
45
+ throw new Error('insecure scheduled-loop state');
46
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8'));
47
+ if (!validFile(parsed) || parsed.role !== role)
48
+ throw new Error('invalid scheduled-loop state');
49
+ restored = parsed;
50
+ chmodSync(this.path, 0o600);
51
+ }
52
+ catch {
53
+ corrupt = true;
54
+ try {
55
+ renameSync(this.path, `${this.path}.corrupt-${now}`);
56
+ }
57
+ catch { }
58
+ }
59
+ }
60
+ this.fresh = !restored;
61
+ this.state = restored ?? {
62
+ version: 1, role, generation: '', clock: { lastWallMs: now },
63
+ health: corrupt ? 'degraded' : 'healthy', anomaly: corrupt ? 'corrupt_state_recovered' : null,
64
+ loops: {},
65
+ };
66
+ this.reconcile(definitions, now, Boolean(restored));
67
+ if (corrupt)
68
+ this.log(`[${role}] scheduled loops recovered corrupt state; delayed cadence reinitialized`);
69
+ }
70
+ reconcile(definitions, now, recoverActive = false) {
71
+ const next = {};
72
+ for (const definition of definitions) {
73
+ const old = this.state.loops[definition.name];
74
+ if (old?.definitionHash === definition.definitionHash) {
75
+ next[definition.name] = {
76
+ ...old, promptHash: definition.promptHash, enabled: definition.enabled,
77
+ };
78
+ }
79
+ else {
80
+ const nominal = now + definition.initialDelayMs;
81
+ next[definition.name] = {
82
+ ...(old ?? {}),
83
+ definitionHash: definition.definitionHash, promptHash: definition.promptHash,
84
+ enabled: definition.enabled,
85
+ nextScheduledAt: new Date(nominal).toISOString(),
86
+ nextDueAt: new Date(nominal + deterministicJitter(definition.role, definition.name, nominal, definition.jitterMs)).toISOString(),
87
+ lastScheduledAt: old?.lastScheduledAt ?? null,
88
+ lastStartedAt: old?.lastStartedAt ?? null,
89
+ lastFinishedAt: old?.lastFinishedAt ?? null,
90
+ lastOutcome: old?.lastOutcome ?? null,
91
+ lastCancellationSource: old?.lastCancellationSource ?? null,
92
+ lastRunId: old?.lastRunId ?? null,
93
+ activeRunId: old?.activeRunId ?? null,
94
+ counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
95
+ operatorDisabled: old?.operatorDisabled ?? false,
96
+ };
97
+ }
98
+ if (recoverActive && next[definition.name].activeRunId) {
99
+ const item = next[definition.name];
100
+ item.counts.failed = increment(item.counts.failed);
101
+ item.lastOutcome = 'abandoned_restart';
102
+ item.lastFinishedAt = new Date(now).toISOString();
103
+ item.lastError = { kind: 'abandoned_restart', at: new Date(now).toISOString() };
104
+ item.activeRunId = null;
105
+ this.state.health = 'degraded';
106
+ this.state.anomaly = 'abandoned_restart';
107
+ }
108
+ }
109
+ this.state.loops = next;
110
+ this.state.generation = createHash('sha256').update(JSON.stringify(definitions.map(item => ({
111
+ name: item.name, definitionHash: item.definitionHash, promptHash: item.promptHash,
112
+ })))).digest('hex');
113
+ this.state.clock.lastWallMs = now;
114
+ this.persist();
115
+ }
116
+ persist() {
117
+ replaceFileAtomically(this.path, JSON.stringify(this.state, null, 2) + '\n', 0o600);
118
+ chmodSync(this.path, 0o600);
119
+ }
120
+ }
121
+ function validFile(value) {
122
+ if (!value || typeof value !== 'object')
123
+ return false;
124
+ const file = value;
125
+ if (file.version !== 1 || typeof file.role !== 'string' || !file.clock
126
+ || !Number.isSafeInteger(file.clock.lastWallMs) || !file.loops || typeof file.loops !== 'object')
127
+ return false;
128
+ return (file.health === 'healthy' || file.health === 'degraded' || file.health === 'failed')
129
+ && (file.anomaly === null || typeof file.anomaly === 'string')
130
+ && Object.values(file.loops).every(item => item && typeof item === 'object'
131
+ && typeof item.definitionHash === 'string' && typeof item.promptHash === 'string'
132
+ && typeof item.nextScheduledAt === 'string' && typeof item.nextDueAt === 'string'
133
+ && Number.isFinite(Date.parse(item.nextScheduledAt)) && Number.isFinite(Date.parse(item.nextDueAt))
134
+ && typeof item.enabled === 'boolean' && typeof item.operatorDisabled === 'boolean'
135
+ && item.counts && Object.values(item.counts).every(count => Number.isSafeInteger(count) && count >= 0)
136
+ && (item.activeRunId === null || typeof item.activeRunId === 'string'));
137
+ }
138
+ function safeStateFile(path) {
139
+ try {
140
+ const stat = lstatSync(path);
141
+ const uid = process.getuid?.();
142
+ return stat.isFile() && !stat.isSymbolicLink() && (stat.mode & 0o777) === 0o600
143
+ && (uid === undefined || stat.uid === uid);
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
package/dist/monitor.js CHANGED
@@ -115,6 +115,30 @@ export function filterEvents(events, wakeSources) {
115
115
  const set = new Set(wakeSources);
116
116
  return events.filter(e => e.event !== undefined && set.has(e.event));
117
117
  }
118
+ /**
119
+ * A reconnect or the short coalescing poll may overlap the preceding daemon
120
+ * page. Collapse only events with a stable notification ID; ID-less events are
121
+ * retained because two otherwise-identical introductions may be distinct.
122
+ * The key deliberately contains only fields already allowed in the body-free
123
+ * notification contract.
124
+ */
125
+ function notificationKey(event) {
126
+ const id = event.event === 'file_received' ? event.file_id : event.msg_id;
127
+ if (id === undefined)
128
+ return undefined;
129
+ return `${event.event ?? ''}\u0000${event.from ?? ''}\u0000${String(id)}`;
130
+ }
131
+ function appendUniqueEvents(target, additions) {
132
+ const seen = new Set(target.map(notificationKey).filter((key) => key !== undefined));
133
+ for (const event of additions) {
134
+ const key = notificationKey(event);
135
+ if (key !== undefined && seen.has(key))
136
+ continue;
137
+ target.push(event);
138
+ if (key !== undefined)
139
+ seen.add(key);
140
+ }
141
+ }
118
142
  const uniq = (xs) => [...new Set(xs)];
119
143
  const plural = (n, one, many = one + 's') => (n === 1 ? one : many);
120
144
  /**
@@ -401,7 +425,7 @@ export class Monitor {
401
425
  }
402
426
  this.advance(body.cursor, false);
403
427
  const batch = filterEvents(body.events ?? [], this.cfg.wake_sources);
404
- pending.push(...batch);
428
+ appendUniqueEvents(pending, batch);
405
429
  if (pending.length === 0) {
406
430
  this.persistCursor();
407
431
  continue;
@@ -445,7 +469,7 @@ export class Monitor {
445
469
  try {
446
470
  const more = await this.doFetch(String(this.cursor ?? 0), COALESCE_HOLD_MS);
447
471
  this.advance(more.cursor, false);
448
- batch.push(...filterEvents(more.events ?? [], this.cfg.wake_sources));
472
+ appendUniqueEvents(batch, filterEvents(more.events ?? [], this.cfg.wake_sources));
449
473
  }
450
474
  catch { /* no stragglers / abort — deliver what we have */ }
451
475
  }
@@ -0,0 +1,74 @@
1
+ import type { OwnerAttachmentConfig } from '../config.js';
2
+ export interface AttachmentReplyRef {
3
+ wire_id: string;
4
+ sentence?: number;
5
+ }
6
+ export interface IncomingAttachment {
7
+ fileId: number;
8
+ wireId: string;
9
+ senderId: string;
10
+ senderName: string;
11
+ filename: string;
12
+ mime: string;
13
+ size: number;
14
+ status: string;
15
+ date: string;
16
+ kind: 'file' | 'voice_message';
17
+ replyTo: AttachmentReplyRef | null;
18
+ }
19
+ export interface VoiceTranscription {
20
+ configured: boolean;
21
+ attempted: boolean;
22
+ status: 'succeeded' | 'failed' | 'unavailable';
23
+ provider: string | null;
24
+ text: string | null;
25
+ errorCategory: string | null;
26
+ audioPath: string;
27
+ fileWireId: string;
28
+ }
29
+ export interface RetrievedAttachment extends IncomingAttachment {
30
+ path: string;
31
+ sha256: string;
32
+ transcription?: VoiceTranscription;
33
+ }
34
+ export interface AdmittedAttachment {
35
+ wireId: string;
36
+ filename: string;
37
+ path: string;
38
+ declaredMime: string;
39
+ detectedMime: string;
40
+ size: number;
41
+ sha256: string;
42
+ kind: 'file' | 'voice_message';
43
+ transcription?: Omit<VoiceTranscription, 'audioPath'>;
44
+ }
45
+ export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
46
+ export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
+ export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
48
+ export declare function prepareAttachmentDirectory(root: string, requestId: string): Promise<string>;
49
+ export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig): Promise<AdmittedAttachment[]>;
50
+ export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
51
+ export declare function removeRequestDirectory(path: string): Promise<void>;
52
+ export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
53
+ export interface PendingAttachmentRequest {
54
+ id: string;
55
+ contact: string;
56
+ originWireId: string;
57
+ fileWireIds: string[];
58
+ createdAt: number;
59
+ }
60
+ export declare class AttachmentRecoveryState {
61
+ private readonly path;
62
+ private pending;
63
+ private corrupt;
64
+ constructor(path: string);
65
+ integrity(): boolean;
66
+ list(): PendingAttachmentRequest[];
67
+ add(item: PendingAttachmentRequest): void;
68
+ remove(id: string): void;
69
+ cleanup(now: number, retentionMs: number): number;
70
+ private assertHealthy;
71
+ private persist;
72
+ }
73
+ export declare function safeField(value: unknown, max: number): string;
74
+ export declare function sanitizeFilename(value: string): string;