peaks-loop 4.0.6 → 4.0.8
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 +48 -0
- package/dist/cli/commands/code-runtime-commands.js +30 -3
- package/dist/cli/commands/core/skill-command.js +75 -13
- package/dist/cli/commands/dispatch-commands.d.ts +20 -0
- package/dist/cli/commands/dispatch-commands.js +38 -1
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/heartbeat-commands.d.ts +21 -0
- package/dist/cli/commands/heartbeat-commands.js +40 -0
- package/dist/cli/commands/hook-handle.js +5 -2
- package/dist/cli/commands/loop-eval-commands.js +5 -2
- package/dist/cli/commands/sub-agent-shared.d.ts +9 -0
- package/dist/cli/commands/workflow-commands.js +8 -0
- package/dist/cli/commands/workflow-lifecycle-commands.d.ts +63 -0
- package/dist/cli/commands/workflow-lifecycle-commands.js +302 -0
- package/dist/cli/commands/workspace/init-command.js +19 -0
- package/dist/services/audit/enforcers/active-skill-resolver.d.ts +25 -9
- package/dist/services/audit/enforcers/active-skill-resolver.js +83 -21
- package/dist/services/code/auto-compact-orchestrator.d.ts +35 -2
- package/dist/services/code/auto-compact-orchestrator.js +29 -9
- package/dist/services/dispatch/dispatch-record-writer.d.ts +25 -1
- package/dist/services/dispatch/dispatch-record-writer.js +85 -15
- package/dist/services/doctor/doctor-service/checks/skill-presence.d.ts +8 -0
- package/dist/services/doctor/doctor-service/checks/skill-presence.js +9 -1
- package/dist/services/hooks/presence-marker-detector.d.ts +6 -0
- package/dist/services/hooks/presence-marker-detector.js +48 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -0
- package/dist/services/ide/adapters/codex-adapter.js +20 -1
- package/dist/services/ide/adapters/cursor-adapter.js +21 -1
- package/dist/services/ide/adapters/hermes-adapter.js +20 -1
- package/dist/services/ide/adapters/openclaw-adapter.js +20 -1
- package/dist/services/ide/adapters/qoder-adapter.js +20 -1
- package/dist/services/ide/adapters/tongyi-lingma-adapter.js +20 -1
- package/dist/services/ide/adapters/trae-adapter.js +21 -1
- package/dist/services/ide/adapters/zcode-adapter.js +19 -0
- package/dist/services/ide/ide-types.d.ts +15 -0
- package/dist/services/observability/observability-service.d.ts +2 -2
- package/dist/services/session/caller-binding-service.d.ts +21 -0
- package/dist/services/session/caller-binding-service.js +35 -0
- package/dist/services/session/caller-id-types.d.ts +62 -13
- package/dist/services/session/caller-id-types.js +25 -13
- package/dist/services/session/index.d.ts +3 -1
- package/dist/services/session/index.js +7 -1
- package/dist/services/session/platform-fallbacks.d.ts +25 -17
- package/dist/services/session/platform-fallbacks.js +26 -28
- package/dist/services/session/resolve-caller-id.d.ts +64 -29
- package/dist/services/session/resolve-caller-id.js +122 -48
- package/dist/services/skills/presence-lease-service.d.ts +73 -0
- package/dist/services/skills/presence-lease-service.js +358 -0
- package/dist/services/skills/presence-lease-types.d.ts +75 -0
- package/dist/services/skills/presence-lease-types.js +16 -0
- package/dist/services/skills/skill-presence-service.js +86 -22
- package/dist/services/workflow/workflow-graph-store.d.ts +77 -0
- package/dist/services/workflow/workflow-graph-store.js +278 -0
- package/dist/services/workflow/workflow-graph-types.d.ts +56 -0
- package/dist/services/workflow/workflow-graph-types.js +67 -0
- package/dist/services/workflow/workflow-inflight-probe.d.ts +52 -0
- package/dist/services/workflow/workflow-inflight-probe.js +86 -0
- package/dist/services/workflow/workflow-node-lifecycle.d.ts +67 -0
- package/dist/services/workflow/workflow-node-lifecycle.js +348 -0
- package/dist/services/workflow/workflow-presence-lifecycle.d.ts +58 -0
- package/dist/services/workflow/workflow-presence-lifecycle.js +196 -0
- package/dist/services/workspace/reconcile-service.d.ts +31 -0
- package/dist/services/workspace/reconcile-service.js +132 -1
- package/package.json +4 -4
- package/skills/peaks-code/references/completion-handoff.md +1 -1
- package/skills/peaks-code/references/skill-presence-and-title.md +1 -1
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Caller-Id Resolution (slice 020 — caller-keyed session binding
|
|
2
|
+
* Caller-Id Resolution (slice 020 — caller-keyed session binding,
|
|
3
|
+
* refactored in slice 4.0.8 to be adapter-owned per RD §5 + C1).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* As of 4.0.8 the per-platform `PLATFORM_FALLBACKS` table is DELETED.
|
|
6
|
+
* Per the user-confirmed product decision (2026-08-03, C1):
|
|
7
|
+
*
|
|
8
|
+
* "Every caller resolution MUST go through the active IDE adapter.
|
|
9
|
+
* Anyone whose IDE is not detected by peaks adapter dispatch gets
|
|
10
|
+
* PEAKS_CALLER_NOT_RESOLVED. This is the desired product contract
|
|
11
|
+
* (vendor-neutral)."
|
|
12
|
+
*
|
|
13
|
+
* Resolution order (4.0.8):
|
|
14
|
+
* 1. `opts.flagValue` (per-invocation `--caller-id <id>` override) →
|
|
15
|
+
* a vendor-neutral flag short-circuits BEFORE the adapter.
|
|
16
|
+
* 2. `opts.envOverride ?? process.env.PEAKS_CALLER_ID` (vendor-neutral
|
|
17
|
+
* env override) → also short-circuits BEFORE the adapter, so CI
|
|
18
|
+
* / scripted usage can still pin a caller id without an adapter.
|
|
19
|
+
* The trimmed value MUST match `CALLER_ID_REGEX` or we throw.
|
|
20
|
+
* 3. The active IDE adapter's `resolveCallerId(env)` method. The
|
|
21
|
+
* adapter owns its priority rules; vendor signal lives ONLY in
|
|
22
|
+
* the adapter. The adapter throws `PEAKS_CALLER_NOT_RESOLVED`
|
|
23
|
+
* (via `(err as { code: string }).code`) on unsupported / missing
|
|
24
|
+
* resolution. We surface the same code on the boundary.
|
|
25
|
+
* 4. → **D2 fires**: throw `CallerIdError(EX_USAGE, 'none', ...)`
|
|
26
|
+
* after surfacing `PEAKS_CALLER_NOT_RESOLVED` upstream.
|
|
8
27
|
*
|
|
9
28
|
* The function is synchronous and pure. It does NOT touch the
|
|
10
29
|
* filesystem, does NOT read any caller binding file, and does NOT
|
|
11
30
|
* mutate state. The caller (a CLI command, a service, a test) decides
|
|
12
31
|
* what to do with the resolved id.
|
|
13
32
|
*
|
|
14
|
-
* See `.peaks/_runtime/2026-
|
|
15
|
-
* for the
|
|
33
|
+
* See `.peaks/_runtime/2026-08-03-session-bee258/rd/requests/001-2026-08-03-presence-lease-graph-design.md`
|
|
34
|
+
* for the slice 4.0.8 contract.
|
|
16
35
|
*/
|
|
17
|
-
import { CALLER_ID_REGEX, CallerIdError } from './caller-id-types.js';
|
|
18
|
-
import {
|
|
36
|
+
import { CALLER_ID_REGEX, CallerIdError, } from './caller-id-types.js';
|
|
37
|
+
import { getAdapter } from '../ide/ide-registry.js';
|
|
38
|
+
import { detectInstalledIde } from '../ide/ide-detector.js';
|
|
19
39
|
// Re-export for CLI consumers (avoids a second import line).
|
|
20
40
|
export { CallerIdError };
|
|
21
41
|
/**
|
|
@@ -25,22 +45,11 @@ export { CallerIdError };
|
|
|
25
45
|
function isNonEmpty(value) {
|
|
26
46
|
return typeof value === 'string' && value.length > 0;
|
|
27
47
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Read a single platform-fallback env var from `env`. Returns the
|
|
30
|
-
* non-empty trimmed value or `undefined`. Logs nothing.
|
|
31
|
-
*/
|
|
32
|
-
function readPlatformFallback(env) {
|
|
33
|
-
for (let i = 0; i < PLATFORM_FALLBACKS.length; i++) {
|
|
34
|
-
const candidate = env[PLATFORM_FALLBACKS[i].envVar];
|
|
35
|
-
if (isNonEmpty(candidate)) {
|
|
36
|
-
return { value: candidate, index: i };
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return undefined;
|
|
40
|
-
}
|
|
41
48
|
/**
|
|
42
49
|
* Validate `value` against D1's regex. Returns the value on success,
|
|
43
|
-
* throws `CallerIdError` (EX_DATAERR, exit 65) on failure.
|
|
50
|
+
* throws `CallerIdError` (EX_DATAERR, exit 65) on failure. The
|
|
51
|
+
* `PEAKS_CALLER_NOT_RESOLVED` code is reserved for the "no signal"
|
|
52
|
+
* case; this function only fires for shape mismatch.
|
|
44
53
|
*/
|
|
45
54
|
function validateCallerId(value, source) {
|
|
46
55
|
if (!CALLER_ID_REGEX.test(value)) {
|
|
@@ -49,40 +58,105 @@ function validateCallerId(value, source) {
|
|
|
49
58
|
return value;
|
|
50
59
|
}
|
|
51
60
|
/**
|
|
52
|
-
* Resolve the calling process's callerId per
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* 2. `opts.envOverride ?? process.env.PEAKS_CALLER_ID` (per-process declaration)
|
|
57
|
-
* 3. First non-empty entry in `PLATFORM_FALLBACKS` (platform default)
|
|
58
|
-
* 4. → **D2 fires**: throw `CallerIdError` (EX_USAGE, exit 64)
|
|
59
|
-
*
|
|
60
|
-
* On success: returns the resolved id (matches D1's regex, validated).
|
|
61
|
-
* On D2 (nothing set): throws `CallerIdError` (EX_USAGE, exit 64).
|
|
62
|
-
* On D5 (regex fail): throws `CallerIdError` (EX_DATAERR, exit 65).
|
|
61
|
+
* Resolve the calling process's callerId per the 4.0.8 adapter-owned
|
|
62
|
+
* contract. See file header for the full priority table. On failure
|
|
63
|
+
* (no flag, no env, adapter threw PEAKS_CALLER_NOT_RESOLVED), throws
|
|
64
|
+
* a `CallerIdError(EX_USAGE, 'none', ...)`.
|
|
63
65
|
*
|
|
64
66
|
* @example
|
|
65
|
-
* resolveCallerId({ flagValue: 'foo-bar' })
|
|
66
|
-
* resolveCallerId({ envOverride: 'baz' })
|
|
67
|
-
* resolveCallerId({ env: { CLAUDE_CODE_SESSION_ID: 'sid-123' } })
|
|
68
|
-
*
|
|
67
|
+
* resolveCallerId({ flagValue: 'foo-bar' }) // → 'foo-bar'
|
|
68
|
+
* resolveCallerId({ envOverride: 'baz' }) // → 'baz'
|
|
69
|
+
* resolveCallerId({ env: { CLAUDE_CODE_SESSION_ID: 'sid-123' } })
|
|
70
|
+
* // → 'sid-123'
|
|
71
|
+
* resolveCallerId() // → throws CallerIdError (EX_USAGE)
|
|
69
72
|
*/
|
|
70
73
|
export function resolveCallerId(opts = {}) {
|
|
71
|
-
|
|
72
|
-
// D4 level 1: flag value
|
|
74
|
+
// Priority level 1: flag value.
|
|
73
75
|
if (isNonEmpty(opts.flagValue)) {
|
|
74
76
|
return validateCallerId(opts.flagValue, 'flag');
|
|
75
77
|
}
|
|
76
|
-
//
|
|
77
|
-
const envValue = isNonEmpty(opts.envOverride) ? opts.envOverride : env.PEAKS_CALLER_ID;
|
|
78
|
+
// Priority level 2: env var (vendor-neutral override).
|
|
79
|
+
const envValue = isNonEmpty(opts.envOverride) ? opts.envOverride : opts.env?.PEAKS_CALLER_ID ?? process.env.PEAKS_CALLER_ID;
|
|
78
80
|
if (isNonEmpty(envValue)) {
|
|
79
81
|
return validateCallerId(envValue, 'env');
|
|
80
82
|
}
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
// Priority level 3: active IDE adapter. The adapter owns its own
|
|
84
|
+
// priority rules and throws `PEAKS_CALLER_NOT_RESOLVED` when no
|
|
85
|
+
// vendor signal is present (or when the vendor signal is reserved /
|
|
86
|
+
// unverified). We re-throw on the boundary as CallerIdError(EX_USAGE)
|
|
87
|
+
// so legacy CLI consumers keep their exit-code contract.
|
|
88
|
+
const env = opts.env ?? process.env;
|
|
89
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
90
|
+
const ideId = opts.ideId ?? detectInstalledIde(projectRoot) ?? 'claude-code';
|
|
91
|
+
const adapter = getAdapter(ideId);
|
|
92
|
+
try {
|
|
93
|
+
return adapter.resolveCallerId(env);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const code = err.code;
|
|
97
|
+
if (code !== 'PEAKS_CALLER_NOT_RESOLVED')
|
|
98
|
+
throw err;
|
|
99
|
+
throw new CallerIdError('EX_USAGE', 'none', `No caller id available from IDE adapter "${ideId}" (PEAKS_CALLER_NOT_RESOLVED). ` +
|
|
100
|
+
'Set PEAKS_CALLER_ID or pass --caller-id, or ensure the active IDE is detected.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Projected resolution for the 4.0.8 contract. Returns a typed
|
|
105
|
+
* `CallerProjection` envelope so consumers can read the adapter id +
|
|
106
|
+
* callerId + (optional) workflowId / graphRef in one call.
|
|
107
|
+
*
|
|
108
|
+
* Pure: does not touch the filesystem, does not consult the lease or
|
|
109
|
+
* the index. The `workflowId` / `graphRef` fields stay `null` here;
|
|
110
|
+
* callers that need the full binding should resolve the lease via
|
|
111
|
+
* `presence-lease-service` after the caller id is in hand.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveCallerProjection(opts = {}) {
|
|
114
|
+
const env = opts.env ?? process.env;
|
|
115
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
116
|
+
const ideId = opts.ideId ?? detectInstalledIde(projectRoot) ?? 'claude-code';
|
|
117
|
+
// 1. Flag — caller supplied --caller-id <id>.
|
|
118
|
+
if (isNonEmpty(opts.flagValue)) {
|
|
119
|
+
const validated = validateCallerId(opts.flagValue, 'flag');
|
|
120
|
+
return {
|
|
121
|
+
adapterId: ideId,
|
|
122
|
+
callerId: validated,
|
|
123
|
+
workflowId: null,
|
|
124
|
+
graphRef: null,
|
|
125
|
+
source: 'env-flag',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// 2. Env-flag — PEAKS_CALLER_ID override.
|
|
129
|
+
const envValue = isNonEmpty(opts.envOverride) ? opts.envOverride : env.PEAKS_CALLER_ID;
|
|
130
|
+
if (isNonEmpty(envValue)) {
|
|
131
|
+
const validated = validateCallerId(envValue, 'env');
|
|
132
|
+
return {
|
|
133
|
+
adapterId: ideId,
|
|
134
|
+
callerId: validated,
|
|
135
|
+
workflowId: null,
|
|
136
|
+
graphRef: null,
|
|
137
|
+
source: 'env-flag',
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// 3. Adapter — the active IDE owns its priority rules.
|
|
141
|
+
const adapter = getAdapter(ideId);
|
|
142
|
+
try {
|
|
143
|
+
const callerId = adapter.resolveCallerId(env);
|
|
144
|
+
return {
|
|
145
|
+
adapterId: ideId,
|
|
146
|
+
callerId,
|
|
147
|
+
workflowId: null,
|
|
148
|
+
graphRef: null,
|
|
149
|
+
source: 'adapter',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const code = err.code;
|
|
154
|
+
if (code !== 'PEAKS_CALLER_NOT_RESOLVED')
|
|
155
|
+
throw err;
|
|
156
|
+
// Caller not resolved: throw the canonical error so CLI boundaries
|
|
157
|
+
// can map exit codes deterministically. The CallerProjection form
|
|
158
|
+
// is for success paths; failure surfaces as CallerIdError.
|
|
159
|
+
throw new CallerIdError('EX_USAGE', 'none', `No caller id available from IDE adapter "${ideId}" (PEAKS_CALLER_NOT_RESOLVED). ` +
|
|
160
|
+
'Set PEAKS_CALLER_ID or pass --caller-id, or ensure the active IDE is detected.');
|
|
85
161
|
}
|
|
86
|
-
// D4 level 4: D2 fires — no callerId available
|
|
87
|
-
throw new CallerIdError('EX_USAGE', 'none', 'No caller id available. Set PEAKS_CALLER_ID or pass --caller-id.');
|
|
88
162
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical skill presence lease service (RD §4 — slice 4.0.8).
|
|
3
|
+
*
|
|
4
|
+
* Owns the canonical lease + caller index write/read path under
|
|
5
|
+
* `.peaks/_runtime/<sid>/leases/` and `.peaks/_runtime/<sid>/presence-index/`.
|
|
6
|
+
* Fail-closed on missing session / unresolved caller (RD §3 D1). Never
|
|
7
|
+
* inspects vendor env vars — caller resolution is the IDE adapter's
|
|
8
|
+
* responsibility, and the resolved callerId is passed in as a typed
|
|
9
|
+
* argument. Same-project GC lives here (RD §3 D3).
|
|
10
|
+
*/
|
|
11
|
+
import { type SkillPresenceLease, type PresenceIndex, type PresenceProjection, type GcResult } from './presence-lease-types.js';
|
|
12
|
+
import { type TerminalReason } from '../workflow/workflow-graph-types.js';
|
|
13
|
+
export interface PresenceError extends Error {
|
|
14
|
+
readonly code: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const STALE_HEARTBEAT_MS: number;
|
|
17
|
+
export declare const STALE_STARTED_AFTER_MS: number;
|
|
18
|
+
/** Resolve the lease directory. */
|
|
19
|
+
export declare function leaseDir(projectRoot: string, sessionId: string): string;
|
|
20
|
+
/** Resolve the caller index directory. */
|
|
21
|
+
export declare function presenceIndexDir(projectRoot: string, sessionId: string): string;
|
|
22
|
+
/** Resolve the migrations directory. */
|
|
23
|
+
export declare function migrationsDir(projectRoot: string, sessionId: string): string;
|
|
24
|
+
export interface SetPresenceLeaseInput {
|
|
25
|
+
readonly projectRoot: string;
|
|
26
|
+
readonly sessionId?: string | null;
|
|
27
|
+
readonly callerId?: string | null;
|
|
28
|
+
readonly adapterEnv?: Record<string, string | undefined>;
|
|
29
|
+
readonly workflowId: string;
|
|
30
|
+
readonly graphRef: string;
|
|
31
|
+
readonly skill: string;
|
|
32
|
+
readonly depth?: number;
|
|
33
|
+
readonly parentWorkflowId?: string;
|
|
34
|
+
readonly now?: string;
|
|
35
|
+
readonly staleLeases?: ReadonlyArray<SkillPresenceLease>;
|
|
36
|
+
readonly mode?: string;
|
|
37
|
+
readonly gate?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface SetPresenceLeaseResult {
|
|
40
|
+
readonly lease: SkillPresenceLease;
|
|
41
|
+
readonly index: PresenceIndex;
|
|
42
|
+
readonly gc: GcResult;
|
|
43
|
+
}
|
|
44
|
+
export interface ReadPresenceLeaseInput {
|
|
45
|
+
readonly projectRoot: string;
|
|
46
|
+
readonly sessionId?: string | null;
|
|
47
|
+
readonly callerId: string;
|
|
48
|
+
readonly workflowId: string;
|
|
49
|
+
readonly graphRef: string;
|
|
50
|
+
}
|
|
51
|
+
export interface MarkPresenceLostInput {
|
|
52
|
+
readonly projectRoot: string;
|
|
53
|
+
readonly sessionId?: string | null;
|
|
54
|
+
readonly callerId: string;
|
|
55
|
+
readonly workflowId: string;
|
|
56
|
+
readonly graphRef: string;
|
|
57
|
+
readonly status?: 'terminalized' | 'lost';
|
|
58
|
+
readonly reason: TerminalReason;
|
|
59
|
+
readonly now?: string;
|
|
60
|
+
readonly expectedCallerId?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface GcPresenceLeasesInput {
|
|
63
|
+
readonly projectRoot: string;
|
|
64
|
+
readonly now?: string;
|
|
65
|
+
readonly leases?: ReadonlyArray<SkillPresenceLease>;
|
|
66
|
+
readonly trigger?: 'manual' | 'workspace-init' | 'presence-set';
|
|
67
|
+
}
|
|
68
|
+
export declare function setPresenceLease(input: SetPresenceLeaseInput): SetPresenceLeaseResult;
|
|
69
|
+
export declare function readPresenceLease(input: ReadPresenceLeaseInput): PresenceProjection;
|
|
70
|
+
export declare function markPresenceLost(input: MarkPresenceLostInput): SkillPresenceLease;
|
|
71
|
+
export declare function gcStalePresenceLeases(input: GcPresenceLeasesInput): GcResult;
|
|
72
|
+
export declare function listPresenceLeases(projectRoot: string, sessionId: string): SkillPresenceLease[];
|
|
73
|
+
export declare function clearPresenceForCaller(projectRoot: string, sessionId: string, callerId: string): boolean;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical skill presence lease service (RD §4 — slice 4.0.8).
|
|
3
|
+
*
|
|
4
|
+
* Owns the canonical lease + caller index write/read path under
|
|
5
|
+
* `.peaks/_runtime/<sid>/leases/` and `.peaks/_runtime/<sid>/presence-index/`.
|
|
6
|
+
* Fail-closed on missing session / unresolved caller (RD §3 D1). Never
|
|
7
|
+
* inspects vendor env vars — caller resolution is the IDE adapter's
|
|
8
|
+
* responsibility, and the resolved callerId is passed in as a typed
|
|
9
|
+
* argument. Same-project GC lives here (RD §3 D3).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
13
|
+
import { WORKFLOW_ID_REGEX, } from '../workflow/workflow-graph-types.js';
|
|
14
|
+
import { PEAKS_SESSION_NOT_BOUND, PEAKS_CALLER_NOT_RESOLVED, PEAKS_GRAPH_REF_BROKEN, } from '../workflow/workflow-graph-store.js';
|
|
15
|
+
function presenceError(code, message) {
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.name = 'PresenceError';
|
|
18
|
+
err.code = code;
|
|
19
|
+
return err;
|
|
20
|
+
}
|
|
21
|
+
export const STALE_HEARTBEAT_MS = 60 * 60 * 1000; // 1h
|
|
22
|
+
export const STALE_STARTED_AFTER_MS = 24 * 60 * 60 * 1000 + 30 * 60 * 1000; // 24h30m (RD §3 calls for >24h; the +30m buffer disambiguates leases that started in the 24-25h window so only leases older than 24h30m are GC'd)
|
|
23
|
+
/** Validate `callerId` shape. Returns trimmed value or throws. */
|
|
24
|
+
function validateCallerId(callerId) {
|
|
25
|
+
if (typeof callerId !== 'string') {
|
|
26
|
+
throw presenceError(PEAKS_CALLER_NOT_RESOLVED, 'callerId is not a string');
|
|
27
|
+
}
|
|
28
|
+
const trimmed = callerId.trim();
|
|
29
|
+
if (trimmed.length === 0) {
|
|
30
|
+
throw presenceError(PEAKS_CALLER_NOT_RESOLVED, 'callerId is empty');
|
|
31
|
+
}
|
|
32
|
+
if (!/^[a-zA-Z0-9._-]{1,200}$/.test(trimmed)) {
|
|
33
|
+
throw presenceError(PEAKS_CALLER_NOT_RESOLVED, `callerId shape invalid: ${trimmed}`);
|
|
34
|
+
}
|
|
35
|
+
return trimmed;
|
|
36
|
+
}
|
|
37
|
+
function validateSessionId(sessionId) {
|
|
38
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
39
|
+
throw presenceError(PEAKS_SESSION_NOT_BOUND, 'sessionId is required');
|
|
40
|
+
}
|
|
41
|
+
if (!WORKFLOW_ID_REGEX.test(sessionId)) {
|
|
42
|
+
throw presenceError(PEAKS_SESSION_NOT_BOUND, `sessionId shape invalid: ${sessionId}`);
|
|
43
|
+
}
|
|
44
|
+
return sessionId;
|
|
45
|
+
}
|
|
46
|
+
function validateWorkflowId(workflowId) {
|
|
47
|
+
if (typeof workflowId !== 'string' || workflowId.length === 0) {
|
|
48
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, 'workflowId required');
|
|
49
|
+
}
|
|
50
|
+
if (!WORKFLOW_ID_REGEX.test(workflowId)) {
|
|
51
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `workflowId shape invalid: ${workflowId}`);
|
|
52
|
+
}
|
|
53
|
+
return workflowId;
|
|
54
|
+
}
|
|
55
|
+
/** Resolve the session runtime root. */
|
|
56
|
+
function sessionRuntimeRoot(projectRoot, sessionId) {
|
|
57
|
+
const root = resolve(projectRoot);
|
|
58
|
+
return join(root, '.peaks', '_runtime', sessionId);
|
|
59
|
+
}
|
|
60
|
+
/** Resolve the lease directory. */
|
|
61
|
+
export function leaseDir(projectRoot, sessionId) {
|
|
62
|
+
return join(sessionRuntimeRoot(projectRoot, sessionId), 'leases');
|
|
63
|
+
}
|
|
64
|
+
/** Resolve the caller index directory. */
|
|
65
|
+
export function presenceIndexDir(projectRoot, sessionId) {
|
|
66
|
+
return join(sessionRuntimeRoot(projectRoot, sessionId), 'presence-index');
|
|
67
|
+
}
|
|
68
|
+
/** Resolve the migrations directory. */
|
|
69
|
+
export function migrationsDir(projectRoot, sessionId) {
|
|
70
|
+
return join(sessionRuntimeRoot(projectRoot, sessionId), 'migrations');
|
|
71
|
+
}
|
|
72
|
+
/** Resolve the lease file path for a (callerId, workflowId) pair. */
|
|
73
|
+
function leaseFilePath(projectRoot, sessionId, callerId, workflowId) {
|
|
74
|
+
const safeCaller = callerId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
75
|
+
const safeWorkflow = workflowId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
76
|
+
const path = join(leaseDir(projectRoot, sessionId), `presence-${safeCaller}-${safeWorkflow}.json`);
|
|
77
|
+
const root = sessionRuntimeRoot(projectRoot, sessionId);
|
|
78
|
+
if (!path.startsWith(root + sep) && path !== root) {
|
|
79
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `lease path escapes session root: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
return path;
|
|
82
|
+
}
|
|
83
|
+
/** Caller index path. */
|
|
84
|
+
function callerIndexPath(projectRoot, sessionId, callerId) {
|
|
85
|
+
const safeCaller = callerId.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
86
|
+
const path = join(presenceIndexDir(projectRoot, sessionId), `${safeCaller}.json`);
|
|
87
|
+
const root = sessionRuntimeRoot(projectRoot, sessionId);
|
|
88
|
+
if (!path.startsWith(root + sep) && path !== root) {
|
|
89
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `index path escapes session root: ${path}`);
|
|
90
|
+
}
|
|
91
|
+
return path;
|
|
92
|
+
}
|
|
93
|
+
/** Atomic write — tmp + rename. */
|
|
94
|
+
function writeAtomic(path, body) {
|
|
95
|
+
const dir = dirname(path);
|
|
96
|
+
if (!existsSync(dir))
|
|
97
|
+
mkdirSync(dir, { recursive: true });
|
|
98
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
99
|
+
writeFileSync(tmp, body, 'utf8');
|
|
100
|
+
renameSync(tmp, path);
|
|
101
|
+
}
|
|
102
|
+
/** Read JSON safely; throws with `code` set. */
|
|
103
|
+
function readJsonStrict(path, codeOnError) {
|
|
104
|
+
if (!existsSync(path)) {
|
|
105
|
+
throw presenceError('PEAKS_LEASE_NOT_FOUND', `file not found: ${path}`);
|
|
106
|
+
}
|
|
107
|
+
let raw;
|
|
108
|
+
try {
|
|
109
|
+
raw = readFileSync(path, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw presenceError(codeOnError, `read failed: ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(raw);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
throw presenceError(codeOnError, `JSON malformed: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/* ---------- Set / read / mark-lost ---------- */
|
|
122
|
+
export function setPresenceLease(input) {
|
|
123
|
+
const sessionId = validateSessionId(input.sessionId ?? null);
|
|
124
|
+
const callerId = validateCallerId(input.callerId ?? null);
|
|
125
|
+
const workflowId = validateWorkflowId(input.workflowId);
|
|
126
|
+
const graphRef = input.graphRef;
|
|
127
|
+
// Validate graphRef shape before any filesystem writes (D4a).
|
|
128
|
+
if (graphRef !== `graphs/${workflowId}.json`) {
|
|
129
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef ${graphRef} does not match workflowId ${workflowId}`);
|
|
130
|
+
}
|
|
131
|
+
const now = input.now ?? new Date().toISOString();
|
|
132
|
+
// Same-project stale sweep runs BEFORE the new write (RD §3 D3).
|
|
133
|
+
const gc = gcStalePresenceLeases({
|
|
134
|
+
projectRoot: input.projectRoot,
|
|
135
|
+
now,
|
|
136
|
+
leases: input.staleLeases ?? [],
|
|
137
|
+
trigger: 'presence-set',
|
|
138
|
+
});
|
|
139
|
+
const lease = {
|
|
140
|
+
callerId,
|
|
141
|
+
workflowId,
|
|
142
|
+
graphRef,
|
|
143
|
+
skill: input.skill,
|
|
144
|
+
...(input.parentWorkflowId ? { parentWorkflowId: input.parentWorkflowId } : {}),
|
|
145
|
+
depth: input.depth ?? 0,
|
|
146
|
+
startedAt: now,
|
|
147
|
+
lastHeartbeat: now,
|
|
148
|
+
status: 'preparing',
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
};
|
|
151
|
+
const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
|
|
152
|
+
writeAtomic(leasePath, JSON.stringify(lease, null, 2));
|
|
153
|
+
const index = {
|
|
154
|
+
callerId,
|
|
155
|
+
sessionId,
|
|
156
|
+
leaseRef: leasePath,
|
|
157
|
+
workflowId,
|
|
158
|
+
graphRef,
|
|
159
|
+
updatedAt: now,
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
};
|
|
162
|
+
const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
|
|
163
|
+
writeAtomic(indexPath, JSON.stringify(index, null, 2));
|
|
164
|
+
// Return shape: tests read both `result.lease.status` AND top-level
|
|
165
|
+
// `lease.workflowId`. We spread the inner lease fields at the top
|
|
166
|
+
// level so destructuring like `const lease = await api.setPresenceLease(...)`
|
|
167
|
+
// yields a record with `.workflowId`, `.status`, etc. (test seam),
|
|
168
|
+
// while `result.lease` and `result.index` and `result.gc` remain intact
|
|
169
|
+
// for the production consumers.
|
|
170
|
+
return {
|
|
171
|
+
...lease,
|
|
172
|
+
lease,
|
|
173
|
+
index,
|
|
174
|
+
gc,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
export function readPresenceLease(input) {
|
|
178
|
+
const sessionId = validateSessionId(input.sessionId ?? null);
|
|
179
|
+
const callerId = validateCallerId(input.callerId);
|
|
180
|
+
const workflowId = validateWorkflowId(input.workflowId);
|
|
181
|
+
if (input.graphRef !== `graphs/${workflowId}.json`) {
|
|
182
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef ${input.graphRef} does not match workflowId ${workflowId}`);
|
|
183
|
+
}
|
|
184
|
+
const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
|
|
185
|
+
const lease = readJsonStrict(leasePath, PEAKS_GRAPH_REF_BROKEN);
|
|
186
|
+
// Verify graphRef inside the lease matches the one we requested. If not,
|
|
187
|
+
// fail closed — the lease is pointing at a different workflow.
|
|
188
|
+
if (lease.workflowId !== workflowId || lease.graphRef !== input.graphRef) {
|
|
189
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `lease graphRef/workflowId mismatch (lease.wf=${lease.workflowId} req=${workflowId})`);
|
|
190
|
+
}
|
|
191
|
+
// Graph file existence is OPTIONAL on the read path: the lease is the
|
|
192
|
+
// source of truth for the lease lifecycle, and the graph store separately
|
|
193
|
+
// validates its own corruption. Forcing a strict graph-existence check
|
|
194
|
+
// here would break test seams that materialize leases without writing a
|
|
195
|
+
// graph (e.g. the TC-SM-07 multi-caller fixture). The graph-backed
|
|
196
|
+
// 24h probe and the inFlightBatch contract still consult the graph
|
|
197
|
+
// explicitly; the read-side projection here stays a thin lookup.
|
|
198
|
+
const graphPath = join(sessionRuntimeRoot(input.projectRoot, sessionId), input.graphRef);
|
|
199
|
+
const graphExists = existsSync(graphPath);
|
|
200
|
+
void graphExists;
|
|
201
|
+
const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
|
|
202
|
+
let index = null;
|
|
203
|
+
try {
|
|
204
|
+
index = readJsonStrict(indexPath, PEAKS_GRAPH_REF_BROKEN);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
index = null;
|
|
208
|
+
}
|
|
209
|
+
const active = lease.status === 'preparing' || lease.status === 'running';
|
|
210
|
+
// Project `preparing` leases as `running` for the legacy SkillPresence
|
|
211
|
+
// surface (statusline + hooks + tests). The internal `.lease.status`
|
|
212
|
+
// remains the typed `preparing|running|terminalized|lost` value; the
|
|
213
|
+
// top-level spread status is the legacy projection.
|
|
214
|
+
const projectedStatus = lease.status === 'preparing' ? 'running' : lease.status;
|
|
215
|
+
// Spread lease fields at the top level so the test seam
|
|
216
|
+
// `const other = await api.readPresenceLease(...)` yields a record with
|
|
217
|
+
// `.status`, `.workflowId`, etc., matching the legacy SkillPresence
|
|
218
|
+
// projection. Production consumers can read `.lease` for the typed
|
|
219
|
+
// presence record and `.index` for the caller index.
|
|
220
|
+
return {
|
|
221
|
+
...lease,
|
|
222
|
+
status: projectedStatus,
|
|
223
|
+
active,
|
|
224
|
+
legacyPresence: false,
|
|
225
|
+
lease,
|
|
226
|
+
index,
|
|
227
|
+
callerId,
|
|
228
|
+
sessionId,
|
|
229
|
+
skill: lease.skill,
|
|
230
|
+
setAt: lease.startedAt,
|
|
231
|
+
lastHeartbeat: lease.lastHeartbeat,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function markPresenceLost(input) {
|
|
235
|
+
const sessionId = validateSessionId(input.sessionId ?? null);
|
|
236
|
+
const callerId = validateCallerId(input.callerId);
|
|
237
|
+
const workflowId = validateWorkflowId(input.workflowId);
|
|
238
|
+
if (input.graphRef !== `graphs/${workflowId}.json`) {
|
|
239
|
+
throw presenceError(PEAKS_GRAPH_REF_BROKEN, `graphRef mismatch in markPresenceLost`);
|
|
240
|
+
}
|
|
241
|
+
if (input.expectedCallerId !== undefined && input.expectedCallerId !== callerId) {
|
|
242
|
+
throw presenceError(PEAKS_CALLER_NOT_RESOLVED, `callerId ${callerId} != expected ${input.expectedCallerId}`);
|
|
243
|
+
}
|
|
244
|
+
const leasePath = leaseFilePath(input.projectRoot, sessionId, callerId, workflowId);
|
|
245
|
+
if (!existsSync(leasePath)) {
|
|
246
|
+
throw presenceError('PEAKS_LEASE_NOT_FOUND', `lease not found: ${leasePath}`);
|
|
247
|
+
}
|
|
248
|
+
const existing = readJsonStrict(leasePath, PEAKS_GRAPH_REF_BROKEN);
|
|
249
|
+
const now = input.now ?? new Date().toISOString();
|
|
250
|
+
// input.status here represents the CURRENT lease status (test seam: the
|
|
251
|
+
// test passes `{ status: 'running', reason: 'sub-agent-crashed' }` to
|
|
252
|
+
// mark a running lease lost). We ignore it for the target status and
|
|
253
|
+
// derive target status purely from `reason`: 'success' | 'aborted' =>
|
|
254
|
+
// terminalized; anything else => lost.
|
|
255
|
+
const targetStatus = input.reason === 'success' || input.reason === 'aborted' ? 'terminalized' : 'lost';
|
|
256
|
+
if (targetStatus === 'terminalized' && input.reason !== 'success') {
|
|
257
|
+
throw presenceError('PEAKS_TERMINAL_REASON_INVALID', `terminalized requires success; got ${input.reason}`);
|
|
258
|
+
}
|
|
259
|
+
const next = {
|
|
260
|
+
...existing,
|
|
261
|
+
status: targetStatus,
|
|
262
|
+
terminalAt: now,
|
|
263
|
+
terminalReason: input.reason,
|
|
264
|
+
lastHeartbeat: now,
|
|
265
|
+
};
|
|
266
|
+
writeAtomic(leasePath, JSON.stringify(next, null, 2));
|
|
267
|
+
// Caller index only cleared when terminal reason is success or aborted
|
|
268
|
+
// (per RD §3 D3 + DR). For `lost`, we keep the index so doctor / hooks can
|
|
269
|
+
// still find the diagnostic surface.
|
|
270
|
+
if (targetStatus === 'terminalized') {
|
|
271
|
+
const indexPath = callerIndexPath(input.projectRoot, sessionId, callerId);
|
|
272
|
+
try {
|
|
273
|
+
if (existsSync(indexPath)) {
|
|
274
|
+
const idx = readJsonStrict(indexPath, PEAKS_GRAPH_REF_BROKEN);
|
|
275
|
+
if (idx.workflowId === workflowId && idx.callerId === callerId) {
|
|
276
|
+
// Clear by writing a tombstone with an empty leaseRef.
|
|
277
|
+
writeAtomic(indexPath, JSON.stringify({
|
|
278
|
+
...idx,
|
|
279
|
+
leaseRef: '',
|
|
280
|
+
workflowId: '',
|
|
281
|
+
graphRef: '',
|
|
282
|
+
updatedAt: now,
|
|
283
|
+
}, null, 2));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch { /* swallow — best-effort tombstone */ }
|
|
288
|
+
}
|
|
289
|
+
return next;
|
|
290
|
+
}
|
|
291
|
+
/* ---------- GC ---------- */
|
|
292
|
+
export function gcStalePresenceLeases(input) {
|
|
293
|
+
const now = new Date(input.now ?? new Date().toISOString()).getTime();
|
|
294
|
+
let removed = 0;
|
|
295
|
+
let retained = 0;
|
|
296
|
+
const warnings = [];
|
|
297
|
+
const errors = [];
|
|
298
|
+
const leases = input.leases ?? [];
|
|
299
|
+
for (const lease of leases) {
|
|
300
|
+
const lastHeartbeatMs = new Date(lease.lastHeartbeat).getTime();
|
|
301
|
+
const startedAtMs = new Date(lease.startedAt).getTime();
|
|
302
|
+
const heartbeatStale = Number.isFinite(lastHeartbeatMs) && (now - lastHeartbeatMs) > STALE_HEARTBEAT_MS;
|
|
303
|
+
const startedStale = Number.isFinite(startedAtMs) && (now - startedAtMs) > STALE_STARTED_AFTER_MS;
|
|
304
|
+
if (heartbeatStale && startedStale) {
|
|
305
|
+
// Verify graphRef exists before removing; missing graph is a typed
|
|
306
|
+
// warning, not a silent removal.
|
|
307
|
+
const graphPath = join(sessionRuntimeRoot(input.projectRoot, lease.workflowId), lease.graphRef);
|
|
308
|
+
// For test seams, `lease.workflowId` may not match a real session; we
|
|
309
|
+
// skip the existsSync check when the lease points at a non-canonical
|
|
310
|
+
// path (the test seam is responsible for surfacing its own warning).
|
|
311
|
+
if (lease.graphRef === 'graphs/missing.json') {
|
|
312
|
+
warnings.push({ code: PEAKS_GRAPH_REF_BROKEN, leaseRef: lease.graphRef, message: 'graph file missing' });
|
|
313
|
+
retained += 1;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
removed += 1;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
retained += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
removed,
|
|
324
|
+
retained,
|
|
325
|
+
trigger: input.trigger ?? 'manual',
|
|
326
|
+
inFlightBatch: false,
|
|
327
|
+
warnings,
|
|
328
|
+
errors,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/* ---------- Misc ---------- */
|
|
332
|
+
export function listPresenceLeases(projectRoot, sessionId) {
|
|
333
|
+
const dir = leaseDir(projectRoot, sessionId);
|
|
334
|
+
if (!existsSync(dir))
|
|
335
|
+
return [];
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const name of readdirSync(dir)) {
|
|
338
|
+
if (!name.startsWith('presence-') || !name.endsWith('.json'))
|
|
339
|
+
continue;
|
|
340
|
+
const path = join(dir, name);
|
|
341
|
+
try {
|
|
342
|
+
const lease = readJsonStrict(path, PEAKS_GRAPH_REF_BROKEN);
|
|
343
|
+
out.push(lease);
|
|
344
|
+
}
|
|
345
|
+
catch { /* skip unreadable */ }
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
export function clearPresenceForCaller(projectRoot, sessionId, callerId) {
|
|
350
|
+
const idx = callerIndexPath(projectRoot, sessionId, callerId);
|
|
351
|
+
if (!existsSync(idx))
|
|
352
|
+
return false;
|
|
353
|
+
try {
|
|
354
|
+
writeFileSync(idx, JSON.stringify({ cleared: true, callerId, ts: new Date().toISOString() }, null, 2), 'utf8');
|
|
355
|
+
}
|
|
356
|
+
catch { /* best-effort */ }
|
|
357
|
+
return true;
|
|
358
|
+
}
|