chati-dev 4.5.16 → 4.5.27
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/README.md +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
closeSync, constants, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync,
|
|
3
|
+
openSync, readSync, readdirSync, renameSync, unlinkSync, writeSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import { performance } from 'node:perf_hooks';
|
|
3
9
|
import { ContractError, canonicalize, sha256 } from '@chati/core';
|
|
4
10
|
|
|
5
11
|
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
@@ -7,10 +13,46 @@ const DELIVERY_STATES = new Set(['pending', 'sent_unconfirmed', 'confirmed', 're
|
|
|
7
13
|
const RECONCILIATION_STATES = new Set(['not_due', 'pending', 'reconciled', 'drift_detected', 'blocked']);
|
|
8
14
|
const OPERATIONS = new Set(['create', 'update', 'close']);
|
|
9
15
|
export const HOURLY_WATCHER_INTERVAL_MS = 60 * 60 * 1000;
|
|
16
|
+
const OUTBOX_LOCK_TIMEOUT_MS = 5_000;
|
|
17
|
+
const OUTBOX_LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
18
|
+
const OUTBOX_LOCK_STALE_MS = 30_000;
|
|
19
|
+
const OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const CLICKUP_STATE_AUTHORITY_CAPABILITIES = new WeakMap();
|
|
21
|
+
const SIGNED_OUTBOX_KEYS = new Set([
|
|
22
|
+
'schema_version', 'state_revision', 'previous_state_digest', 'authority_context',
|
|
23
|
+
'projections', 'authority_id', 'authority_signature',
|
|
24
|
+
]);
|
|
25
|
+
const UNSIGNED_OUTBOX_KEYS = new Set(['schema_version', 'projections']);
|
|
10
26
|
|
|
11
27
|
function fail(code, message, details = {}) { throw new ContractError(code, message, details); }
|
|
12
28
|
function string(value, code, label) { if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`); }
|
|
13
29
|
function object(value, code, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`); }
|
|
30
|
+
function exactKeys(value, expected, code, label) {
|
|
31
|
+
object(value, code, label);
|
|
32
|
+
const keys = Object.keys(value);
|
|
33
|
+
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
|
34
|
+
fail(code, `${label} has an invalid closed schema`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function assertProjectId(value) {
|
|
38
|
+
string(value, 'INVALID_PROJECT_ID', 'project_id');
|
|
39
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
|
|
40
|
+
fail('INVALID_PROJECT_ID', 'project_id must be one path-inert identifier component');
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
function projectEvolutionPath(root, id, digest) {
|
|
45
|
+
const safeId = assertProjectId(id);
|
|
46
|
+
const evolutionRoot = resolve(root);
|
|
47
|
+
const projectDirectory = resolve(evolutionRoot, safeId);
|
|
48
|
+
const projectRelative = relative(evolutionRoot, projectDirectory);
|
|
49
|
+
if (!projectRelative || projectRelative.startsWith(`..${sep}`) || projectRelative === '..' || isAbsolute(projectRelative)) {
|
|
50
|
+
fail('INVALID_PROJECT_ID', 'project_id must remain inside the configured Project Evolution directory');
|
|
51
|
+
}
|
|
52
|
+
const path = resolve(projectDirectory, `${digest}.json`);
|
|
53
|
+
if (dirname(path) !== projectDirectory) fail('INVALID_PROJECT_ID', 'Project Evolution path escaped its project directory');
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
14
56
|
function timestamp(clock) {
|
|
15
57
|
if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock function is required');
|
|
16
58
|
const result = clock();
|
|
@@ -18,18 +60,326 @@ function timestamp(clock) {
|
|
|
18
60
|
if (typeof value !== 'string' || !RFC3339.test(value) || Number.isNaN(Date.parse(value))) fail('INVALID_CLOCK', 'clock must return a valid RFC3339 timestamp or Date');
|
|
19
61
|
return value;
|
|
20
62
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
|
|
64
|
+
function outboxCore(value) {
|
|
65
|
+
return canonicalize({
|
|
66
|
+
schema_version: value.schema_version,
|
|
67
|
+
state_revision: value.state_revision,
|
|
68
|
+
previous_state_digest: value.previous_state_digest,
|
|
69
|
+
authority_context: value.authority_context,
|
|
70
|
+
projections: value.projections,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function outboxIsAuthenticated(value) {
|
|
75
|
+
return value && typeof value === 'object'
|
|
76
|
+
&& Object.hasOwn(value, 'authority_id')
|
|
77
|
+
&& Object.hasOwn(value, 'authority_signature');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Host-backed capability for authenticating mutable ClickUp outbox snapshots. */
|
|
81
|
+
export class ClickUpStateAuthority {
|
|
82
|
+
constructor({ sign, verify } = {}) {
|
|
83
|
+
if (typeof sign !== 'function' || typeof verify !== 'function') {
|
|
84
|
+
fail('INVALID_CLICKUP_STATE_AUTHORITY', 'ClickUp state authority requires sign and verify callbacks');
|
|
85
|
+
}
|
|
86
|
+
CLICKUP_STATE_AUTHORITY_CAPABILITIES.set(this, Object.freeze({ sign, verify }));
|
|
87
|
+
Object.freeze(this);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function assertAuthorityContext(actual, expected) {
|
|
92
|
+
if (!expected) return;
|
|
93
|
+
object(actual, 'CLICKUP_STATE_CONTEXT_MISMATCH', 'ClickUp outbox authority_context');
|
|
94
|
+
if (sha256(actual) !== sha256(expected)) {
|
|
95
|
+
fail('CLICKUP_STATE_CONTEXT_MISMATCH', 'ClickUp outbox belongs to another project or installation');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function verifyAuthenticatedOutbox(data, {
|
|
100
|
+
stateAuthority = null, requireAuthenticatedState = false, authorityContext = null,
|
|
101
|
+
} = {}) {
|
|
102
|
+
const authenticated = outboxIsAuthenticated(data);
|
|
103
|
+
if (!authenticated) {
|
|
104
|
+
exactKeys(data, UNSIGNED_OUTBOX_KEYS, 'OUTBOX_CORRUPT', 'unsigned ClickUp outbox');
|
|
105
|
+
if (requireAuthenticatedState) {
|
|
106
|
+
fail('UNAUTHENTICATED_CLICKUP_STATE', 'production ClickUp outbox requires host authentication');
|
|
107
|
+
}
|
|
108
|
+
return { data, digest: 'GENESIS' };
|
|
109
|
+
}
|
|
110
|
+
exactKeys(data, SIGNED_OUTBOX_KEYS, 'OUTBOX_CORRUPT', 'authenticated ClickUp outbox');
|
|
111
|
+
if (!Number.isSafeInteger(data.state_revision) || data.state_revision < 1
|
|
112
|
+
|| (data.previous_state_digest !== 'GENESIS' && !/^[a-f0-9]{64}$/.test(data.previous_state_digest))) {
|
|
113
|
+
fail('OUTBOX_CORRUPT', 'authenticated ClickUp outbox revision chain is invalid');
|
|
114
|
+
}
|
|
115
|
+
assertAuthorityContext(data.authority_context, authorityContext);
|
|
116
|
+
const capability = CLICKUP_STATE_AUTHORITY_CAPABILITIES.get(stateAuthority);
|
|
117
|
+
if (!capability) fail('CLICKUP_STATE_AUTHORITY_REQUIRED', 'authenticated ClickUp outbox requires its host authority');
|
|
118
|
+
string(data.authority_id, 'OUTBOX_CORRUPT', 'ClickUp outbox authority_id');
|
|
119
|
+
string(data.authority_signature, 'OUTBOX_CORRUPT', 'ClickUp outbox authority_signature');
|
|
120
|
+
const core = outboxCore(data);
|
|
121
|
+
if (capability.verify(core, {
|
|
122
|
+
authority_id: data.authority_id,
|
|
123
|
+
authority_signature: data.authority_signature,
|
|
124
|
+
}) !== true) {
|
|
125
|
+
fail('INVALID_CLICKUP_STATE_SIGNATURE', 'ClickUp outbox host signature is invalid');
|
|
126
|
+
}
|
|
127
|
+
return { data, digest: sha256(core) };
|
|
26
128
|
}
|
|
27
|
-
function
|
|
28
|
-
|
|
129
|
+
function sameIdentity(left, right) {
|
|
130
|
+
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sameSnapshot(left, right) {
|
|
134
|
+
return sameIdentity(left, right)
|
|
135
|
+
&& left?.mode === right?.mode
|
|
136
|
+
&& left?.uid === right?.uid
|
|
137
|
+
&& left?.gid === right?.gid
|
|
138
|
+
&& left?.nlink === right?.nlink
|
|
139
|
+
&& left?.size === right?.size
|
|
140
|
+
&& left?.mtimeMs === right?.mtimeMs
|
|
141
|
+
&& left?.ctimeMs === right?.ctimeMs;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runSecurityTestHook(name, payload) {
|
|
145
|
+
const hook = globalThis[Symbol.for('chati.tracking-clickup.security-test-hooks')]?.[name];
|
|
146
|
+
if (typeof hook === 'function') hook(payload);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readDescriptorBounded(descriptor, maxBytes) {
|
|
150
|
+
const bytes = Buffer.allocUnsafe(maxBytes + 1);
|
|
151
|
+
let offset = 0;
|
|
152
|
+
while (offset < bytes.length) {
|
|
153
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
154
|
+
if (count === 0) break;
|
|
155
|
+
offset += count;
|
|
156
|
+
}
|
|
157
|
+
return bytes.subarray(0, offset);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function openPinnedTrackingDirectory(path, { create = false } = {}) {
|
|
161
|
+
if (process.platform !== 'linux') fail('UNSUPPORTED_TRACKING_STORAGE_PLATFORM', 'tracking authority storage requires Linux descriptor-relative filesystem operations');
|
|
162
|
+
const absolute = resolve(path);
|
|
163
|
+
const root = parse(absolute).root;
|
|
164
|
+
let descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
165
|
+
try {
|
|
166
|
+
for (const segment of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
167
|
+
const childPath = join(`/proc/self/fd/${descriptor}`, segment);
|
|
168
|
+
let child;
|
|
169
|
+
try { child = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0)); }
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (error?.code !== 'ENOENT' || !create) {
|
|
172
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('UNSAFE_OUTBOX_DIRECTORY', 'tracking directory chain must contain only real directories');
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
try { mkdirSync(childPath, { mode: 0o700 }); }
|
|
176
|
+
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
177
|
+
child = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
178
|
+
}
|
|
179
|
+
const stat = fstatSync(child);
|
|
180
|
+
if (!stat.isDirectory()) {
|
|
181
|
+
closeSync(child);
|
|
182
|
+
fail('UNSAFE_OUTBOX_DIRECTORY', 'tracking directory chain must contain only real directories');
|
|
183
|
+
}
|
|
184
|
+
closeSync(descriptor);
|
|
185
|
+
descriptor = child;
|
|
186
|
+
}
|
|
187
|
+
return Object.freeze({ absolute, descriptor, identity: fstatSync(descriptor), reference: `/proc/self/fd/${descriptor}` });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
closeSync(descriptor);
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function trackingEntry(directory, name) {
|
|
195
|
+
if (basename(name) !== name || name === '.' || name === '..') fail('UNSAFE_OUTBOX_FILE', 'tracking entry must be one path segment');
|
|
196
|
+
return join(directory.reference, name);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function assertPinnedTrackingDirectory(directory) {
|
|
200
|
+
const pinned = fstatSync(directory.descriptor);
|
|
201
|
+
if (!pinned.isDirectory() || !sameIdentity(pinned, directory.identity)) fail('UNSAFE_OUTBOX_DIRECTORY', 'pinned tracking directory changed');
|
|
202
|
+
let current;
|
|
203
|
+
try { current = openPinnedTrackingDirectory(directory.absolute); }
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (error?.code === 'UNSAFE_OUTBOX_DIRECTORY') throw error;
|
|
206
|
+
fail('UNSAFE_OUTBOX_DIRECTORY', 'tracking directory chain changed during storage');
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
if (!sameIdentity(current.identity, directory.identity)) fail('UNSAFE_OUTBOX_DIRECTORY', 'tracking directory chain changed during storage');
|
|
210
|
+
} finally { closeSync(current.descriptor); }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readRegular(path, { allowMissing = false, maxBytes = OUTBOX_MAX_BYTES, directory = null } = {}) {
|
|
214
|
+
let activeDirectory = directory;
|
|
215
|
+
const ownedDirectory = activeDirectory === null;
|
|
216
|
+
if (ownedDirectory) {
|
|
217
|
+
try { activeDirectory = openPinnedTrackingDirectory(dirname(path)); }
|
|
218
|
+
catch (error) { if (allowMissing && error?.code === 'ENOENT') return null; throw error; }
|
|
219
|
+
}
|
|
220
|
+
const entry = trackingEntry(activeDirectory, basename(path));
|
|
221
|
+
let descriptor;
|
|
222
|
+
try {
|
|
223
|
+
descriptor = openSync(entry, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
226
|
+
if (allowMissing && error?.code === 'ENOENT') return null;
|
|
227
|
+
if (['ELOOP', 'ENXIO', 'EISDIR'].includes(error?.code)) fail('UNSAFE_OUTBOX_FILE', 'tracking state must not be a symlink, FIFO or directory');
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
const before = fstatSync(descriptor);
|
|
232
|
+
if (!before.isFile() || before.nlink !== 1) fail('UNSAFE_OUTBOX_FILE', 'tracking state must be a regular single-link file');
|
|
233
|
+
if (before.size > maxBytes) fail('OUTBOX_SIZE_LIMIT', `tracking state exceeds the ${maxBytes}-byte safety limit`);
|
|
234
|
+
runSecurityTestHook('afterTrackingStateStat', { path, descriptor });
|
|
235
|
+
const content = readDescriptorBounded(descriptor, maxBytes);
|
|
236
|
+
if (content.length > maxBytes) fail('OUTBOX_SIZE_LIMIT', `tracking state exceeds the ${maxBytes}-byte safety limit`);
|
|
237
|
+
const after = fstatSync(descriptor);
|
|
238
|
+
if (!after.isFile() || after.nlink !== 1 || !sameSnapshot(before, after)) {
|
|
239
|
+
fail('OUTBOX_STORAGE_CONFLICT', 'tracking state changed while it was being read');
|
|
240
|
+
}
|
|
241
|
+
assertPinnedTrackingDirectory(activeDirectory);
|
|
242
|
+
return { content: content.toString('utf8'), stat: after };
|
|
243
|
+
} finally {
|
|
244
|
+
closeSync(descriptor);
|
|
245
|
+
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function writeAtomic(path, data, maxBytes = OUTBOX_MAX_BYTES, { directory = null } = {}) {
|
|
250
|
+
const content = `${JSON.stringify(canonicalize(data), null, 2)}\n`;
|
|
251
|
+
const bytes = Buffer.from(content);
|
|
252
|
+
if (bytes.length > maxBytes) fail('OUTBOX_SIZE_LIMIT', `tracking state exceeds the ${maxBytes}-byte safety limit`);
|
|
253
|
+
const activeDirectory = directory ?? openPinnedTrackingDirectory(dirname(path), { create: true });
|
|
254
|
+
const ownedDirectory = directory === null;
|
|
255
|
+
const temporary = trackingEntry(activeDirectory, `${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
256
|
+
const destination = trackingEntry(activeDirectory, basename(path));
|
|
257
|
+
let temporaryDescriptor;
|
|
258
|
+
try {
|
|
259
|
+
const existing = readRegular(path, { allowMissing: true, maxBytes, directory: activeDirectory });
|
|
260
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
261
|
+
let offset = 0;
|
|
262
|
+
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
263
|
+
fsyncSync(temporaryDescriptor);
|
|
264
|
+
closeSync(temporaryDescriptor);
|
|
265
|
+
temporaryDescriptor = undefined;
|
|
266
|
+
assertPinnedTrackingDirectory(activeDirectory);
|
|
267
|
+
let current = null;
|
|
268
|
+
try { current = lstatSync(destination); } catch (error) { if (error?.code !== 'ENOENT') throw error; }
|
|
269
|
+
if ((existing === null && current !== null)
|
|
270
|
+
|| (existing !== null && (!current || current.isSymbolicLink() || !current.isFile()
|
|
271
|
+
|| current.nlink !== 1 || !sameIdentity(current, existing.stat)))) {
|
|
272
|
+
fail('OUTBOX_STORAGE_CONFLICT', 'tracking state changed during atomic persistence');
|
|
273
|
+
}
|
|
274
|
+
renameSync(temporary, destination);
|
|
275
|
+
fsyncSync(activeDirectory.descriptor);
|
|
276
|
+
assertPinnedTrackingDirectory(activeDirectory);
|
|
277
|
+
} finally {
|
|
278
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
279
|
+
try { unlinkSync(temporary); } catch { /* leave an unverified temporary untouched */ }
|
|
280
|
+
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export function readClickUpOutbox(path, {
|
|
284
|
+
directory = null, state_authority = null, require_authenticated_state = false,
|
|
285
|
+
authority_context = null, allow_missing_authenticated_state = false,
|
|
286
|
+
} = {}) {
|
|
287
|
+
let stored;
|
|
288
|
+
try { stored = readRegular(path, { allowMissing: true, directory }); }
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
291
|
+
stored = null;
|
|
292
|
+
}
|
|
293
|
+
if (stored === null) {
|
|
294
|
+
if (require_authenticated_state && !allow_missing_authenticated_state) {
|
|
295
|
+
fail('MISSING_CLICKUP_STATE', 'authenticated ClickUp outbox is missing');
|
|
296
|
+
}
|
|
297
|
+
return authority_context
|
|
298
|
+
? { schema_version: 1, state_revision: 0, previous_state_digest: 'GENESIS', authority_context: canonicalize(authority_context), projections: [] }
|
|
299
|
+
: { schema_version: 1, projections: [] };
|
|
300
|
+
}
|
|
29
301
|
let data;
|
|
30
|
-
try { data = JSON.parse(
|
|
302
|
+
try { data = JSON.parse(stored.content); } catch { fail('OUTBOX_CORRUPT', 'outbox JSON is malformed'); }
|
|
31
303
|
if (data?.schema_version !== 1 || !Array.isArray(data.projections)) fail('OUTBOX_CORRUPT', 'outbox schema is invalid');
|
|
32
|
-
return data
|
|
304
|
+
return verifyAuthenticatedOutbox(data, {
|
|
305
|
+
stateAuthority: state_authority,
|
|
306
|
+
requireAuthenticatedState: require_authenticated_state,
|
|
307
|
+
authorityContext: authority_context,
|
|
308
|
+
}).data;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function readClickUpDocument(path, { allowMissing = false, maxBytes = OUTBOX_MAX_BYTES } = {}) {
|
|
312
|
+
const stored = readRegular(path, { allowMissing, maxBytes });
|
|
313
|
+
if (stored === null) return null;
|
|
314
|
+
try { return JSON.parse(stored.content); }
|
|
315
|
+
catch { return fail('OUTBOX_CORRUPT', 'tracking JSON document is malformed'); }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function persistImmutableClickUpDocument(path, data, { maxBytes = OUTBOX_MAX_BYTES } = {}) {
|
|
319
|
+
const content = `${JSON.stringify(canonicalize(data), null, 2)}\n`;
|
|
320
|
+
const bytes = Buffer.from(content);
|
|
321
|
+
if (bytes.length > maxBytes) fail('OUTBOX_SIZE_LIMIT', `tracking document exceeds the ${maxBytes}-byte safety limit`);
|
|
322
|
+
const directory = openPinnedTrackingDirectory(dirname(path), { create: true });
|
|
323
|
+
const temporary = trackingEntry(directory, `${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
324
|
+
const destination = trackingEntry(directory, basename(path));
|
|
325
|
+
let temporaryDescriptor;
|
|
326
|
+
try {
|
|
327
|
+
const existing = readRegular(path, { allowMissing: true, maxBytes, directory });
|
|
328
|
+
if (existing !== null) {
|
|
329
|
+
if (existing.content !== content) fail('OUTBOX_STORAGE_CONFLICT', 'immutable tracking document already contains different material');
|
|
330
|
+
return Object.freeze({ written: false, path });
|
|
331
|
+
}
|
|
332
|
+
runSecurityTestHook('beforeTrackingDocumentTempOpen', { path, temporary, directory: directory.absolute });
|
|
333
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
334
|
+
runSecurityTestHook('afterTrackingDocumentTempOpen', { path, temporary, directory: directory.absolute });
|
|
335
|
+
let offset = 0;
|
|
336
|
+
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
337
|
+
fsyncSync(temporaryDescriptor);
|
|
338
|
+
closeSync(temporaryDescriptor);
|
|
339
|
+
temporaryDescriptor = undefined;
|
|
340
|
+
assertPinnedTrackingDirectory(directory);
|
|
341
|
+
try { linkSync(temporary, destination); }
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
344
|
+
const raced = readRegular(path, { maxBytes, directory });
|
|
345
|
+
if (raced.content !== content) fail('OUTBOX_STORAGE_CONFLICT', 'immutable tracking document raced with different material');
|
|
346
|
+
return Object.freeze({ written: false, path });
|
|
347
|
+
}
|
|
348
|
+
fsyncSync(directory.descriptor);
|
|
349
|
+
assertPinnedTrackingDirectory(directory);
|
|
350
|
+
return Object.freeze({ written: true, path });
|
|
351
|
+
} finally {
|
|
352
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
353
|
+
try { unlinkSync(temporary); } catch { /* leave an unverified temporary untouched */ }
|
|
354
|
+
closeSync(directory.descriptor);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function listClickUpDocumentPaths(directoryPath) {
|
|
359
|
+
const directory = openPinnedTrackingDirectory(directoryPath);
|
|
360
|
+
try {
|
|
361
|
+
const names = readdirSync(directory.reference).filter((name) => /^[A-Za-z0-9._-]+\.json$/.test(name)).sort();
|
|
362
|
+
assertPinnedTrackingDirectory(directory);
|
|
363
|
+
return names.map((name) => join(directory.absolute, name));
|
|
364
|
+
} finally { closeSync(directory.descriptor); }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function removeImmutableClickUpDocument(path, { maxBytes = OUTBOX_MAX_BYTES } = {}) {
|
|
368
|
+
const directory = openPinnedTrackingDirectory(dirname(path));
|
|
369
|
+
const destination = trackingEntry(directory, basename(path));
|
|
370
|
+
try {
|
|
371
|
+
const existing = readRegular(path, { allowMissing: true, maxBytes, directory });
|
|
372
|
+
if (existing === null) return false;
|
|
373
|
+
const current = lstatSync(destination);
|
|
374
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || !sameIdentity(current, existing.stat)) {
|
|
375
|
+
fail('OUTBOX_STORAGE_CONFLICT', 'tracking document changed before removal');
|
|
376
|
+
}
|
|
377
|
+
assertPinnedTrackingDirectory(directory);
|
|
378
|
+
unlinkSync(destination);
|
|
379
|
+
fsyncSync(directory.descriptor);
|
|
380
|
+
assertPinnedTrackingDirectory(directory);
|
|
381
|
+
return true;
|
|
382
|
+
} finally { closeSync(directory.descriptor); }
|
|
33
383
|
}
|
|
34
384
|
function closureMutation(payload) {
|
|
35
385
|
const status = typeof payload.status === 'string' ? payload.status.trim().toLowerCase() : '';
|
|
@@ -37,6 +387,69 @@ function closureMutation(payload) {
|
|
|
37
387
|
|| Object.hasOwn(payload, 'end_date') || Object.hasOwn(payload, 'endDate');
|
|
38
388
|
}
|
|
39
389
|
|
|
390
|
+
function processIsAlive(pid) {
|
|
391
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
392
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function readOutboxLock(path) {
|
|
396
|
+
let descriptor;
|
|
397
|
+
try {
|
|
398
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
399
|
+
const before = fstatSync(descriptor);
|
|
400
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 16 * 1024) return null;
|
|
401
|
+
const content = readDescriptorBounded(descriptor, 16 * 1024);
|
|
402
|
+
const after = fstatSync(descriptor);
|
|
403
|
+
if (content.length > 16 * 1024 || !after.isFile() || after.nlink !== 1 || !sameSnapshot(before, after)) return null;
|
|
404
|
+
const owner = JSON.parse(content.toString('utf8'));
|
|
405
|
+
return owner && typeof owner === 'object' && typeof owner.token === 'string' ? owner : null;
|
|
406
|
+
} catch { return null; }
|
|
407
|
+
finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
function recoverStaleOutboxLock(path) {
|
|
412
|
+
let stat;
|
|
413
|
+
try { stat = lstatSync(path); } catch (error) { return error?.code === 'ENOENT'; }
|
|
414
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) return false;
|
|
415
|
+
const owner = readOutboxLock(path);
|
|
416
|
+
if (owner?.hostname === hostname() && processIsAlive(owner.pid)) return false;
|
|
417
|
+
// A process on another host cannot be proven dead from this host. Keep the
|
|
418
|
+
// lock until an operator explicitly reconciles it.
|
|
419
|
+
if (owner?.hostname && owner.hostname !== hostname()) return false;
|
|
420
|
+
if (!owner && Date.now() - stat.mtimeMs < OUTBOX_LOCK_STALE_MS) return false;
|
|
421
|
+
const recoveryPath = `${path}.recovery`;
|
|
422
|
+
let recoveryFd;
|
|
423
|
+
try {
|
|
424
|
+
recoveryFd = openSync(recoveryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
425
|
+
const bytes = Buffer.from(JSON.stringify({ pid: process.pid, hostname: hostname(), token: randomUUID() }));
|
|
426
|
+
writeSync(recoveryFd, bytes, 0, bytes.length);
|
|
427
|
+
fsyncSync(recoveryFd);
|
|
428
|
+
} catch {
|
|
429
|
+
if (recoveryFd !== undefined) closeSync(recoveryFd);
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
const quarantine = `${path}.stale.${process.pid}.${randomUUID()}`;
|
|
433
|
+
try {
|
|
434
|
+
const current = lstatSync(path);
|
|
435
|
+
const currentOwner = readOutboxLock(path);
|
|
436
|
+
if (!sameIdentity(stat, current) || currentOwner?.token !== owner?.token) return false;
|
|
437
|
+
renameSync(path, quarantine);
|
|
438
|
+
const quarantined = lstatSync(quarantine);
|
|
439
|
+
const quarantinedOwner = readOutboxLock(quarantine);
|
|
440
|
+
if (!sameIdentity(stat, quarantined) || quarantinedOwner?.token !== owner?.token) {
|
|
441
|
+
try { if (!existsSync(path)) renameSync(quarantine, path); } catch { /* preserve quarantine fail-closed */ }
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
unlinkSync(quarantine);
|
|
445
|
+
return true;
|
|
446
|
+
} catch (error) { return error?.code === 'ENOENT'; }
|
|
447
|
+
finally {
|
|
448
|
+
if (recoveryFd !== undefined) closeSync(recoveryFd);
|
|
449
|
+
try { unlinkSync(recoveryPath); } catch { /* fail closed if recovery ownership changed */ }
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
40
453
|
/**
|
|
41
454
|
* C-07 local projection authority. It intentionally has no ClickUp SDK,
|
|
42
455
|
* credentials, network client, timer or knowledge-store integration. A caller
|
|
@@ -53,8 +466,19 @@ export class TrackingClickUp {
|
|
|
53
466
|
#preflight;
|
|
54
467
|
#watcherIntervalMs;
|
|
55
468
|
#state;
|
|
469
|
+
#stateAuthority;
|
|
470
|
+
#requireAuthenticatedState;
|
|
471
|
+
#authorityContext;
|
|
472
|
+
#stateDigest = 'GENESIS';
|
|
473
|
+
#lockDepth = 0;
|
|
474
|
+
#activeDirectory;
|
|
56
475
|
|
|
57
|
-
constructor({
|
|
476
|
+
constructor({
|
|
477
|
+
outbox_path, project_evolution_dir, clock, transport = null,
|
|
478
|
+
verify_completion, verify_reference, resolve_reference, preflight = null,
|
|
479
|
+
watcher_interval_ms = HOURLY_WATCHER_INTERVAL_MS,
|
|
480
|
+
state_authority = null, require_authenticated_state = false, authority_context = null,
|
|
481
|
+
} = {}) {
|
|
58
482
|
string(outbox_path, 'INVALID_OUTBOX_PATH', 'outbox_path');
|
|
59
483
|
string(project_evolution_dir, 'INVALID_EVOLUTION_DIR', 'project_evolution_dir');
|
|
60
484
|
if (transport !== null && (!transport || typeof transport.send !== 'function')) fail('INVALID_TRANSPORT', 'transport.send must be a function');
|
|
@@ -72,7 +496,23 @@ export class TrackingClickUp {
|
|
|
72
496
|
this.#resolveReference = resolve_reference;
|
|
73
497
|
this.#preflight = preflight;
|
|
74
498
|
this.#watcherIntervalMs = watcher_interval_ms;
|
|
75
|
-
|
|
499
|
+
if (state_authority !== null && !CLICKUP_STATE_AUTHORITY_CAPABILITIES.has(state_authority)) {
|
|
500
|
+
fail('INVALID_CLICKUP_STATE_AUTHORITY', 'state_authority must be a minted ClickUpStateAuthority');
|
|
501
|
+
}
|
|
502
|
+
if (authority_context !== null) {
|
|
503
|
+
object(authority_context, 'INVALID_CLICKUP_STATE_CONTEXT', 'authority_context');
|
|
504
|
+
for (const field of ['project_id', 'installation_artifact_id']) {
|
|
505
|
+
string(authority_context[field], 'INVALID_CLICKUP_STATE_CONTEXT', `authority_context.${field}`);
|
|
506
|
+
}
|
|
507
|
+
assertProjectId(authority_context.project_id);
|
|
508
|
+
this.#authorityContext = canonicalize(authority_context);
|
|
509
|
+
} else this.#authorityContext = null;
|
|
510
|
+
this.#stateAuthority = state_authority;
|
|
511
|
+
this.#requireAuthenticatedState = require_authenticated_state === true;
|
|
512
|
+
if (this.#requireAuthenticatedState && (!this.#stateAuthority || !this.#authorityContext)) {
|
|
513
|
+
fail('CLICKUP_STATE_AUTHORITY_REQUIRED', 'production ClickUp state requires a host authority and project context');
|
|
514
|
+
}
|
|
515
|
+
this.#loadState({ allowMissing: true });
|
|
76
516
|
// Project Evolution is a derivative, never the only copy of evidence.
|
|
77
517
|
// Rebuild it after an interrupted outbox/evolution write sequence.
|
|
78
518
|
for (const projectId of new Set(this.#state.projections.map((item) => item.project_id))) this.#evolve(projectId);
|
|
@@ -82,7 +522,124 @@ export class TrackingClickUp {
|
|
|
82
522
|
get outboxPath() { return this.#outboxPath; }
|
|
83
523
|
get watcherConfig() { return Object.freeze({ interval_ms: this.#watcherIntervalMs, mode: 'external-one-shot-scheduler' }); }
|
|
84
524
|
|
|
85
|
-
#
|
|
525
|
+
#validateLoadedState() {
|
|
526
|
+
const ids = new Set();
|
|
527
|
+
const idempotencyKeys = new Set();
|
|
528
|
+
let priorCreatedAt = Number.NEGATIVE_INFINITY;
|
|
529
|
+
for (const projection of this.#state.projections) {
|
|
530
|
+
validateProjection(projection);
|
|
531
|
+
if (this.#authorityContext && projection.project_id !== this.#authorityContext.project_id) {
|
|
532
|
+
fail('CLICKUP_STATE_CONTEXT_MISMATCH', 'ClickUp outbox contains a projection from another project');
|
|
533
|
+
}
|
|
534
|
+
if (projection.payload_hash !== sha256(projection.payload)) {
|
|
535
|
+
fail('OUTBOX_SEMANTIC_CORRUPTION', 'ClickUp projection payload_hash does not match its payload');
|
|
536
|
+
}
|
|
537
|
+
if (ids.has(projection.projection_id) || idempotencyKeys.has(projection.idempotency_key)) {
|
|
538
|
+
fail('OUTBOX_SEMANTIC_CORRUPTION', 'ClickUp outbox contains duplicate projection identities');
|
|
539
|
+
}
|
|
540
|
+
ids.add(projection.projection_id);
|
|
541
|
+
idempotencyKeys.add(projection.idempotency_key);
|
|
542
|
+
const createdAt = Date.parse(projection.created_at);
|
|
543
|
+
if (!Number.isFinite(createdAt) || createdAt < priorCreatedAt) {
|
|
544
|
+
fail('OUTBOX_SEMANTIC_CORRUPTION', 'ClickUp outbox projection order is not chronological');
|
|
545
|
+
}
|
|
546
|
+
priorCreatedAt = createdAt;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#loadState({ directory = null, allowMissing = false } = {}) {
|
|
551
|
+
this.#state = readClickUpOutbox(this.#outboxPath, {
|
|
552
|
+
directory,
|
|
553
|
+
state_authority: this.#stateAuthority,
|
|
554
|
+
require_authenticated_state: this.#requireAuthenticatedState,
|
|
555
|
+
authority_context: this.#authorityContext,
|
|
556
|
+
allow_missing_authenticated_state: allowMissing,
|
|
557
|
+
});
|
|
558
|
+
this.#stateDigest = outboxIsAuthenticated(this.#state) ? sha256(outboxCore(this.#state)) : 'GENESIS';
|
|
559
|
+
this.#validateLoadedState();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
#exclusive(action) {
|
|
563
|
+
if (this.#lockDepth > 0) return action();
|
|
564
|
+
const directory = openPinnedTrackingDirectory(dirname(this.#outboxPath), { create: true });
|
|
565
|
+
const path = trackingEntry(directory, `${basename(this.#outboxPath)}.lock`);
|
|
566
|
+
let descriptor;
|
|
567
|
+
let token;
|
|
568
|
+
try {
|
|
569
|
+
const lockDeadline = performance.now() + OUTBOX_LOCK_TIMEOUT_MS;
|
|
570
|
+
while (performance.now() < lockDeadline) {
|
|
571
|
+
let candidate;
|
|
572
|
+
try {
|
|
573
|
+
candidate = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
576
|
+
if (recoverStaleOutboxLock(path)) continue;
|
|
577
|
+
Atomics.wait(OUTBOX_LOCK_WAIT, 0, 0, 4);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
token = randomUUID();
|
|
581
|
+
try {
|
|
582
|
+
const bytes = Buffer.from(JSON.stringify({ schema_version: 1, pid: process.pid, hostname: hostname(), token, acquired_at: new Date().toISOString() }));
|
|
583
|
+
writeSync(candidate, bytes, 0, bytes.length);
|
|
584
|
+
fsyncSync(candidate);
|
|
585
|
+
fsyncSync(directory.descriptor);
|
|
586
|
+
descriptor = candidate;
|
|
587
|
+
break;
|
|
588
|
+
} catch (error) {
|
|
589
|
+
closeSync(candidate);
|
|
590
|
+
try { unlinkSync(path); } catch { /* acquisition failed closed */ }
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (descriptor === undefined) fail('OUTBOX_LOCK_BUSY', 'outbox mutation lock is already held');
|
|
595
|
+
this.#lockDepth = 1;
|
|
596
|
+
this.#activeDirectory = directory;
|
|
597
|
+
assertPinnedTrackingDirectory(directory);
|
|
598
|
+
this.#loadState({ directory, allowMissing: true });
|
|
599
|
+
return action();
|
|
600
|
+
} finally {
|
|
601
|
+
this.#lockDepth = 0;
|
|
602
|
+
this.#activeDirectory = undefined;
|
|
603
|
+
try {
|
|
604
|
+
if (descriptor !== undefined) {
|
|
605
|
+
closeSync(descriptor);
|
|
606
|
+
assertPinnedTrackingDirectory(directory);
|
|
607
|
+
if (readOutboxLock(path)?.token === token) { unlinkSync(path); fsyncSync(directory.descriptor); }
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
closeSync(directory.descriptor);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#persist() {
|
|
616
|
+
if (this.#stateAuthority) {
|
|
617
|
+
const capability = CLICKUP_STATE_AUTHORITY_CAPABILITIES.get(this.#stateAuthority);
|
|
618
|
+
const core = canonicalize({
|
|
619
|
+
schema_version: 1,
|
|
620
|
+
state_revision: (this.#state.state_revision ?? 0) + 1,
|
|
621
|
+
previous_state_digest: this.#stateDigest,
|
|
622
|
+
authority_context: this.#authorityContext,
|
|
623
|
+
projections: this.#state.projections,
|
|
624
|
+
});
|
|
625
|
+
const receipt = capability.sign(core);
|
|
626
|
+
object(receipt, 'INVALID_CLICKUP_STATE_AUTHORITY', 'ClickUp state authority receipt');
|
|
627
|
+
string(receipt.authority_id, 'INVALID_CLICKUP_STATE_AUTHORITY', 'ClickUp state authority receipt.authority_id');
|
|
628
|
+
string(receipt.authority_signature, 'INVALID_CLICKUP_STATE_AUTHORITY', 'ClickUp state authority receipt.authority_signature');
|
|
629
|
+
if (capability.verify(core, receipt) !== true) {
|
|
630
|
+
fail('INVALID_CLICKUP_STATE_AUTHORITY', 'ClickUp state authority did not verify its own receipt');
|
|
631
|
+
}
|
|
632
|
+
this.#state = {
|
|
633
|
+
...core,
|
|
634
|
+
projections: this.#state.projections,
|
|
635
|
+
...canonicalize(receipt),
|
|
636
|
+
};
|
|
637
|
+
this.#stateDigest = sha256(core);
|
|
638
|
+
} else {
|
|
639
|
+
this.#state = { schema_version: 1, projections: this.#state.projections };
|
|
640
|
+
}
|
|
641
|
+
writeAtomic(this.#outboxPath, this.#state, OUTBOX_MAX_BYTES, { directory: this.#activeDirectory });
|
|
642
|
+
}
|
|
86
643
|
#find(projectionId) {
|
|
87
644
|
string(projectionId, 'INVALID_PROJECTION_ID', 'projection_id');
|
|
88
645
|
const projection = this.#state.projections.find((item) => item.projection_id === projectionId);
|
|
@@ -90,13 +647,18 @@ export class TrackingClickUp {
|
|
|
90
647
|
return projection;
|
|
91
648
|
}
|
|
92
649
|
#evolve(projectId) {
|
|
93
|
-
const
|
|
650
|
+
const safeProjectId = assertProjectId(projectId);
|
|
651
|
+
const relevant = this.#state.projections.filter((item) => item.project_id === safeProjectId);
|
|
94
652
|
const latestByTask = new Map();
|
|
95
653
|
for (const item of relevant) latestByTask.set(item.task_id, item);
|
|
96
|
-
const sourceProjections = relevant.map((
|
|
654
|
+
const sourceProjections = relevant.map((item) => {
|
|
655
|
+
const source = { ...item };
|
|
656
|
+
delete source.project_evolution_ref;
|
|
657
|
+
return source;
|
|
658
|
+
});
|
|
97
659
|
const snapshot = canonicalize({
|
|
98
660
|
schema_version: 1,
|
|
99
|
-
project_id:
|
|
661
|
+
project_id: safeProjectId,
|
|
100
662
|
current_phase: [...latestByTask.values()].at(-1)?.handoff_view.phase ?? 'unknown',
|
|
101
663
|
tasks: [...latestByTask.values()].map((item) => ({ task_id: item.task_id, attempt_id: item.attempt_id, phase: item.handoff_view.phase, canonical_state: item.journal_view.task_state, blockers: item.journal_view.blockers, delivery_state: item.delivery_state, reconciliation_state: item.reconciliation_state })),
|
|
102
664
|
decisions: [...new Map(relevant.flatMap((item) => item.decisions.map((decision) => [decision.decision_ref, decision]))).values()],
|
|
@@ -108,9 +670,9 @@ export class TrackingClickUp {
|
|
|
108
670
|
source_outbox_hash: sha256({ schema_version: 1, projections: sourceProjections }),
|
|
109
671
|
});
|
|
110
672
|
const digest = sha256(snapshot);
|
|
111
|
-
const path =
|
|
673
|
+
const path = projectEvolutionPath(this.#evolutionDir, safeProjectId, digest);
|
|
112
674
|
writeAtomic(path, snapshot);
|
|
113
|
-
return Object.freeze({ project_evolution_ref: `project-evolution://${
|
|
675
|
+
return Object.freeze({ project_evolution_ref: `project-evolution://${safeProjectId}/${digest}`, path, digest });
|
|
114
676
|
}
|
|
115
677
|
#setEvolution(projection) {
|
|
116
678
|
const evolution = this.#evolve(projection.project_id);
|
|
@@ -125,10 +687,15 @@ export class TrackingClickUp {
|
|
|
125
687
|
}
|
|
126
688
|
|
|
127
689
|
enqueue(input = {}) {
|
|
690
|
+
return this.#exclusive(() => {
|
|
128
691
|
object(input, 'INVALID_PROJECTION', 'projection');
|
|
129
692
|
const { projection_id, idempotency_key, canonical_event_id, task_id, clickup_ref, operation, payload, project_id, handoff_ref, journal_ref } = input;
|
|
130
693
|
const attempt_id = input.attempt_id ?? `project:${project_id}:no-attempt`;
|
|
131
694
|
for (const [name, value] of Object.entries({ projection_id, idempotency_key, canonical_event_id, task_id, clickup_ref, project_id })) string(value, 'INVALID_PROJECTION', name);
|
|
695
|
+
assertProjectId(project_id);
|
|
696
|
+
if (this.#authorityContext && project_id !== this.#authorityContext.project_id) {
|
|
697
|
+
fail('CLICKUP_STATE_CONTEXT_MISMATCH', 'projection belongs to another project than the authenticated outbox');
|
|
698
|
+
}
|
|
132
699
|
string(attempt_id, 'INVALID_PROJECTION', 'attempt_id');
|
|
133
700
|
if (!OPERATIONS.has(operation)) fail('INVALID_PROJECTION_OPERATION', 'operation must be create, update, or close');
|
|
134
701
|
object(payload, 'INVALID_PROJECTION', 'payload');
|
|
@@ -170,17 +737,25 @@ export class TrackingClickUp {
|
|
|
170
737
|
this.#setEvolution(projection);
|
|
171
738
|
this.#persist();
|
|
172
739
|
return Object.freeze(canonicalize(projection));
|
|
740
|
+
});
|
|
173
741
|
}
|
|
174
742
|
|
|
175
743
|
deliver({ projection_id } = {}) {
|
|
744
|
+
return this.#exclusive(() => {
|
|
176
745
|
const projection = this.#find(projection_id);
|
|
177
746
|
if (!this.#transport) fail('TRANSPORT_NOT_CONFIGURED', 'no authorized projection transport is configured');
|
|
178
747
|
if (projection.delivery_state === 'confirmed') return Object.freeze(canonicalize(projection));
|
|
179
748
|
if (projection.delivery_state === 'permanent_failure') fail('PERMANENT_FAILURE', 'permanently failed projection cannot be retried');
|
|
180
|
-
if (projection.delivery_state
|
|
749
|
+
if (['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) fail('UNCONFIRMED_DELIVERY_REQUIRES_LOOKUP', 'uncertain delivery must be resolved by idempotency lookup before resend');
|
|
181
750
|
this.#preflightTransport(projection);
|
|
182
751
|
projection.attempts += 1;
|
|
183
752
|
projection.last_attempt_at = timestamp(this.#clock);
|
|
753
|
+
// Persist uncertainty before crossing the external side-effect boundary.
|
|
754
|
+
// A lost response must be reconciled by lookup, never blindly resent.
|
|
755
|
+
projection.delivery_state = 'sent_unconfirmed';
|
|
756
|
+
delete projection.failure_code;
|
|
757
|
+
this.#setEvolution(projection);
|
|
758
|
+
this.#persist();
|
|
184
759
|
try {
|
|
185
760
|
const response = this.#transport.send(Object.freeze(canonicalize(projection)));
|
|
186
761
|
object(response, 'INVALID_TRANSPORT_RECEIPT', 'transport response');
|
|
@@ -190,20 +765,52 @@ export class TrackingClickUp {
|
|
|
190
765
|
projection.delivery_state = 'confirmed';
|
|
191
766
|
projection.receipt_ref = response.receipt_ref;
|
|
192
767
|
projection.reconciliation_state = 'pending';
|
|
193
|
-
}
|
|
768
|
+
}
|
|
194
769
|
} catch (error) {
|
|
195
|
-
|
|
770
|
+
if (error?.permanent === true) projection.delivery_state = 'permanent_failure';
|
|
196
771
|
projection.failure_code = typeof error?.code === 'string' ? error.code : 'TRANSPORT_FAILURE';
|
|
197
772
|
}
|
|
198
773
|
this.#setEvolution(projection);
|
|
199
774
|
this.#persist();
|
|
200
775
|
return Object.freeze(canonicalize(projection));
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Persists uncertainty before a separately authorized MCP caller sends. */
|
|
780
|
+
prepareExternalDelivery({ projection_id } = {}) {
|
|
781
|
+
return this.#exclusive(() => {
|
|
782
|
+
const projection = this.#find(projection_id);
|
|
783
|
+
if (projection.delivery_state === 'confirmed') return Object.freeze(canonicalize(projection));
|
|
784
|
+
if (projection.delivery_state === 'permanent_failure') fail('PERMANENT_FAILURE', 'permanently failed projection cannot be retried');
|
|
785
|
+
if (['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) {
|
|
786
|
+
fail('UNCONFIRMED_DELIVERY_REQUIRES_LOOKUP', 'uncertain delivery must be resolved by idempotency lookup before resend');
|
|
787
|
+
}
|
|
788
|
+
validateProjection(projection);
|
|
789
|
+
projection.attempts += 1;
|
|
790
|
+
projection.last_attempt_at = timestamp(this.#clock);
|
|
791
|
+
projection.delivery_state = 'sent_unconfirmed';
|
|
792
|
+
delete projection.failure_code;
|
|
793
|
+
this.#setEvolution(projection);
|
|
794
|
+
this.#persist();
|
|
795
|
+
return Object.freeze(canonicalize(projection));
|
|
796
|
+
});
|
|
201
797
|
}
|
|
202
798
|
|
|
203
799
|
/** Records a receipt produced by an independently authorized MCP boundary. */
|
|
204
|
-
confirmExternalDelivery({
|
|
800
|
+
confirmExternalDelivery({
|
|
801
|
+
projection_id, receipt_ref, expected_delivery_attempt, expected_delivery_attempted_at,
|
|
802
|
+
} = {}) {
|
|
803
|
+
return this.#exclusive(() => {
|
|
205
804
|
const projection = this.#find(projection_id);
|
|
206
805
|
string(receipt_ref, 'INVALID_TRANSPORT_RECEIPT', 'receipt_ref');
|
|
806
|
+
if (!Number.isInteger(expected_delivery_attempt) || expected_delivery_attempt < 1) {
|
|
807
|
+
fail('INVALID_TRANSPORT_RECEIPT', 'expected_delivery_attempt must be a positive integer');
|
|
808
|
+
}
|
|
809
|
+
string(expected_delivery_attempted_at, 'INVALID_TRANSPORT_RECEIPT', 'expected_delivery_attempted_at');
|
|
810
|
+
if (projection.attempts !== expected_delivery_attempt
|
|
811
|
+
|| projection.last_attempt_at !== expected_delivery_attempted_at) {
|
|
812
|
+
fail('STALE_EXTERNAL_DELIVERY_RESULT', 'external delivery receipt does not match the current delivery attempt');
|
|
813
|
+
}
|
|
207
814
|
if (this.#verifyReference(receipt_ref, 'receipt') !== true) {
|
|
208
815
|
fail('UNVERIFIED_RECEIPT', 'external delivery receipt must be an immutable verified reference');
|
|
209
816
|
}
|
|
@@ -212,17 +819,59 @@ export class TrackingClickUp {
|
|
|
212
819
|
if (projection.receipt_ref !== receipt_ref) fail('RECEIPT_CONFLICT', 'projection is already confirmed by a different receipt');
|
|
213
820
|
return Object.freeze(canonicalize(projection));
|
|
214
821
|
}
|
|
822
|
+
if (!['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) {
|
|
823
|
+
fail('DELIVERY_INTENT_REQUIRED', 'external delivery must be marked sent_unconfirmed before it can be confirmed');
|
|
824
|
+
}
|
|
215
825
|
projection.delivery_state = 'confirmed';
|
|
216
826
|
projection.receipt_ref = receipt_ref;
|
|
217
827
|
projection.reconciliation_state = 'pending';
|
|
218
|
-
projection.last_attempt_at
|
|
219
|
-
projection.attempts += 1;
|
|
828
|
+
projection.last_attempt_at ??= timestamp(this.#clock);
|
|
220
829
|
this.#setEvolution(projection);
|
|
221
830
|
this.#persist();
|
|
222
831
|
return Object.freeze(canonicalize(projection));
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Applies an immutable lookup result supplied by an authorized MCP caller. */
|
|
836
|
+
resolveExternalLookup({
|
|
837
|
+
projection_id, result, evidence_ref, expected_delivery_attempt, expected_delivery_attempted_at,
|
|
838
|
+
} = {}) {
|
|
839
|
+
return this.#exclusive(() => {
|
|
840
|
+
const projection = this.#find(projection_id);
|
|
841
|
+
if (!Number.isInteger(expected_delivery_attempt) || expected_delivery_attempt < 1) {
|
|
842
|
+
fail('INVALID_LOOKUP_RESULT', 'expected_delivery_attempt must be a positive integer');
|
|
843
|
+
}
|
|
844
|
+
string(expected_delivery_attempted_at, 'INVALID_LOOKUP_RESULT', 'expected_delivery_attempted_at');
|
|
845
|
+
if (projection.attempts !== expected_delivery_attempt
|
|
846
|
+
|| projection.last_attempt_at !== expected_delivery_attempted_at) {
|
|
847
|
+
fail('STALE_EXTERNAL_DELIVERY_RESULT', 'lookup result does not match the current delivery attempt');
|
|
848
|
+
}
|
|
849
|
+
if (!['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) {
|
|
850
|
+
fail('NOT_SENT_UNCONFIRMED', 'idempotency lookup only applies to uncertain delivery');
|
|
851
|
+
}
|
|
852
|
+
object(result, 'INVALID_LOOKUP_RESULT', 'idempotency lookup result');
|
|
853
|
+
string(evidence_ref, 'INVALID_LOOKUP_RESULT', 'lookup evidence_ref');
|
|
854
|
+
if (this.#verifyReference(evidence_ref, 'receipt') !== true) fail('UNVERIFIED_RECEIPT', 'lookup evidence must be an immutable verified reference');
|
|
855
|
+
if (result.receipt_ref !== undefined) {
|
|
856
|
+
string(result.receipt_ref, 'INVALID_LOOKUP_RESULT', 'receipt_ref');
|
|
857
|
+
if (this.#verifyReference(result.receipt_ref, 'receipt') !== true) fail('UNVERIFIED_RECEIPT', 'lookup receipt must be an immutable verified reference');
|
|
858
|
+
projection.delivery_state = 'confirmed';
|
|
859
|
+
projection.receipt_ref = result.receipt_ref;
|
|
860
|
+
projection.reconciliation_state = 'pending';
|
|
861
|
+
} else if (result.state === 'not_found') {
|
|
862
|
+
projection.delivery_state = 'pending';
|
|
863
|
+
} else if (result.state !== 'unknown') {
|
|
864
|
+
fail('INVALID_LOOKUP_RESULT', 'lookup requires receipt_ref, not_found, or unknown');
|
|
865
|
+
}
|
|
866
|
+
projection.lookup_evidence_ref = evidence_ref;
|
|
867
|
+
this.#setEvolution(projection);
|
|
868
|
+
this.#persist();
|
|
869
|
+
return Object.freeze(canonicalize(projection));
|
|
870
|
+
});
|
|
223
871
|
}
|
|
224
872
|
|
|
225
873
|
reconcile({ projection_id } = {}) {
|
|
874
|
+
return this.#exclusive(() => {
|
|
226
875
|
const projection = this.#find(projection_id);
|
|
227
876
|
if (!this.#transport || typeof this.#transport.reconcile !== 'function') fail('RECONCILIATION_NOT_CONFIGURED', 'no authorized reconciliation transport is configured');
|
|
228
877
|
if (projection.delivery_state !== 'confirmed') fail('RECEIPT_REQUIRED', 'reconciliation requires confirmed delivery receipt');
|
|
@@ -235,11 +884,13 @@ export class TrackingClickUp {
|
|
|
235
884
|
this.#setEvolution(projection);
|
|
236
885
|
this.#persist();
|
|
237
886
|
return Object.freeze(canonicalize(projection));
|
|
887
|
+
});
|
|
238
888
|
}
|
|
239
889
|
|
|
240
890
|
resolveSentUnconfirmed({ projection_id } = {}) {
|
|
891
|
+
return this.#exclusive(() => {
|
|
241
892
|
const projection = this.#find(projection_id);
|
|
242
|
-
if (projection.delivery_state
|
|
893
|
+
if (!['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) fail('NOT_SENT_UNCONFIRMED', 'idempotency lookup only applies to uncertain delivery');
|
|
243
894
|
if (!this.#transport || typeof this.#transport.lookup !== 'function') fail('IDEMPOTENCY_LOOKUP_NOT_CONFIGURED', 'sent_unconfirmed requires an idempotency lookup transport');
|
|
244
895
|
this.#preflightTransport(projection);
|
|
245
896
|
const result = this.#transport.lookup(Object.freeze(canonicalize(projection)));
|
|
@@ -255,27 +906,33 @@ export class TrackingClickUp {
|
|
|
255
906
|
this.#setEvolution(projection);
|
|
256
907
|
this.#persist();
|
|
257
908
|
return Object.freeze(canonicalize(projection));
|
|
909
|
+
});
|
|
258
910
|
}
|
|
259
911
|
|
|
260
912
|
watchOnce() {
|
|
913
|
+
return this.#exclusive(() => {
|
|
261
914
|
// Intentionally one-shot: a host scheduler may invoke this hourly. No timer is created.
|
|
262
915
|
const replayed = [];
|
|
263
916
|
for (const projection of this.#state.projections) {
|
|
264
|
-
if (projection.delivery_state
|
|
917
|
+
if (['sent_unconfirmed', 'retryable_failure'].includes(projection.delivery_state)) {
|
|
265
918
|
const resolved = this.resolveSentUnconfirmed({ projection_id: projection.projection_id });
|
|
266
919
|
if (resolved.delivery_state === 'pending') replayed.push(this.deliver({ projection_id: projection.projection_id }));
|
|
267
|
-
} else if (
|
|
920
|
+
} else if (projection.delivery_state === 'pending') replayed.push(this.deliver({ projection_id: projection.projection_id }));
|
|
268
921
|
}
|
|
269
922
|
return Object.freeze(replayed);
|
|
923
|
+
});
|
|
270
924
|
}
|
|
271
925
|
|
|
272
926
|
rederiveProjectEvolution({ project_id } = {}) {
|
|
273
|
-
|
|
927
|
+
return this.#exclusive(() => {
|
|
928
|
+
assertProjectId(project_id);
|
|
274
929
|
if (!this.#state.projections.some((item) => item.project_id === project_id)) fail('PROJECT_NOT_FOUND', 'project has no durable projections', { project_id });
|
|
275
930
|
return this.#evolve(project_id);
|
|
931
|
+
});
|
|
276
932
|
}
|
|
277
933
|
|
|
278
934
|
recordMetrics({ projection_id, actual_human_effort, actual_ai_processing, rework_cycles, rework_review_refs, completion } = {}) {
|
|
935
|
+
return this.#exclusive(() => {
|
|
279
936
|
const projection = this.#find(projection_id);
|
|
280
937
|
object(actual_human_effort, 'INVALID_HUMAN_EFFORT', 'actual_human_effort');
|
|
281
938
|
object(actual_ai_processing, 'INVALID_AI_PROCESSING', 'actual_ai_processing');
|
|
@@ -293,7 +950,8 @@ export class TrackingClickUp {
|
|
|
293
950
|
// No wall-clock value exists here. Provenance is durable with the metric.
|
|
294
951
|
const metricMaterial = canonicalize({ actual_human_effort, actual_ai_processing, rework_cycles, rework_review_refs, completion_id: completion.completion_id });
|
|
295
952
|
if (projection.metrics) {
|
|
296
|
-
const
|
|
953
|
+
const existingMaterial = { ...projection.metrics };
|
|
954
|
+
delete existingMaterial.recorded_at;
|
|
297
955
|
if (sha256(existingMaterial) !== sha256(metricMaterial)) fail('METRICS_ALREADY_RECORDED', 'projection metrics are immutable');
|
|
298
956
|
return Object.freeze(canonicalize(projection));
|
|
299
957
|
}
|
|
@@ -301,12 +959,14 @@ export class TrackingClickUp {
|
|
|
301
959
|
this.#setEvolution(projection);
|
|
302
960
|
this.#persist();
|
|
303
961
|
return Object.freeze(canonicalize(projection));
|
|
962
|
+
});
|
|
304
963
|
}
|
|
305
964
|
}
|
|
306
965
|
|
|
307
966
|
export function validateProjection(projection) {
|
|
308
967
|
object(projection, 'INVALID_PROJECTION', 'projection');
|
|
309
968
|
for (const field of ['projection_id', 'idempotency_key', 'canonical_event_id', 'task_id', 'clickup_ref', 'project_id', 'payload_hash']) string(projection[field], 'INVALID_PROJECTION', field);
|
|
969
|
+
assertProjectId(projection.project_id);
|
|
310
970
|
if (!OPERATIONS.has(projection.operation)) fail('INVALID_PROJECTION_OPERATION', 'operation is invalid');
|
|
311
971
|
if (!DELIVERY_STATES.has(projection.delivery_state)) fail('INVALID_DELIVERY_STATE', 'delivery_state is invalid');
|
|
312
972
|
if (!RECONCILIATION_STATES.has(projection.reconciliation_state)) fail('INVALID_RECONCILIATION_STATE', 'reconciliation_state is invalid');
|