gestalt-mobile 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/client/assets/index-B0Uq4OB0.css +1 -0
- package/dist/client/assets/index-orfZBEaD.js +27 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +6 -0
- package/dist/server/server/composition.js +413 -21
- package/dist/server/server/features/agent-activity/model.js +197 -0
- package/dist/server/server/features/agent-activity/registry.js +155 -0
- package/dist/server/server/features/autopilot/application/policy.js +55 -0
- package/dist/server/server/features/autopilot/application/ports.js +6 -0
- package/dist/server/server/features/autopilot/application/service.js +542 -0
- package/dist/server/server/features/autopilot/domain/autopilot-session.js +40 -0
- package/dist/server/server/features/autopilot/register-routes.js +9 -0
- package/dist/server/server/features/autopilot/toggle/endpoint.js +32 -0
- package/dist/server/server/features/org-plan-attention/application/ports.js +6 -0
- package/dist/server/server/features/org-plan-attention/get-active/endpoint.js +22 -0
- package/dist/server/server/features/org-plan-attention/register-routes.js +11 -0
- package/dist/server/server/features/org-plan-attention/resolve/endpoint.js +42 -0
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +4 -0
- package/dist/server/server/features/sessions/get-history/endpoint.js +62 -2
- package/dist/server/server/features/sessions/get-history/history-mapper.js +17 -5
- package/dist/server/server/features/sessions/get-session/endpoint.js +3 -1
- package/dist/server/server/features/sessions/interaction/kind.js +1 -0
- package/dist/server/server/features/sessions/interaction/response-validator.js +5 -1
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +2 -0
- package/dist/server/server/features/sessions/model/relay-session.js +3 -0
- package/dist/server/server/features/sessions/refresh-activity/endpoint.js +14 -0
- package/dist/server/server/features/sessions/register-routes.js +15 -2
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +15 -2
- package/dist/server/server/features/sessions/restore-session/endpoint.js +21 -5
- package/dist/server/server/features/skills/list-available/endpoint.js +3 -4
- package/dist/server/server/features/skills/model/skill-profile.js +13 -5
- package/dist/server/server/platform/codex/activity-facts.js +88 -0
- package/dist/server/server/platform/codex/server-request.js +21 -4
- package/dist/server/server/platform/codex/session-runtime.js +114 -22
- package/dist/server/server/platform/persistence/migrate.js +16 -1
- package/dist/server/server/platform/persistence/sqlite-autopilot-store.js +151 -0
- package/dist/server/server/platform/persistence/sqlite-event-journal.js +92 -4
- package/dist/server/server/platform/persistence/sqlite-pending-interaction-store.js +62 -3
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +5 -2
- package/dist/server/shared/contracts/org-plan-attention.js +101 -0
- package/package.json +1 -1
- package/dist/client/assets/index-DgD-0I3M.js +0 -25
- package/dist/client/assets/index-e2lJ5XqL.css +0 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { parseOrgPlanAttentionResponse, toOrgPlanAttentionToolResponse, } from '../../../../shared/contracts/org-plan-attention.js';
|
|
2
|
+
export function registerResolveOrgPlanAttention(app, deps) {
|
|
3
|
+
app.post('/api/sessions/:id/attention/:requestId/resolve', async (request, reply) => {
|
|
4
|
+
const { id: sessionId, requestId } = request.params;
|
|
5
|
+
const body = request.body;
|
|
6
|
+
const validFields = body &&
|
|
7
|
+
Object.keys(body).every((key) => ['operationKey', 'action', 'guidance'].includes(key));
|
|
8
|
+
const response = validFields
|
|
9
|
+
? parseOrgPlanAttentionResponse({
|
|
10
|
+
action: body.action,
|
|
11
|
+
...(body.guidance === undefined ? {} : { guidance: body.guidance }),
|
|
12
|
+
})
|
|
13
|
+
: null;
|
|
14
|
+
const operationKey = typeof body?.operationKey === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.operationKey)
|
|
15
|
+
? body.operationKey
|
|
16
|
+
: null;
|
|
17
|
+
if (!response || !operationKey)
|
|
18
|
+
return reply.code(400).send({ code: 'ATTENTION_RESPONSE_INVALID' });
|
|
19
|
+
const outcome = await deps.resolver.resolve({
|
|
20
|
+
sessionId,
|
|
21
|
+
requestId,
|
|
22
|
+
operationKey,
|
|
23
|
+
response: toOrgPlanAttentionToolResponse(response),
|
|
24
|
+
});
|
|
25
|
+
if (outcome.kind === 'accepted' || outcome.kind === 'replayed')
|
|
26
|
+
return reply.code(202).send({
|
|
27
|
+
accepted: true,
|
|
28
|
+
replayed: outcome.kind === 'replayed',
|
|
29
|
+
resolvedAt: outcome.resolvedAt,
|
|
30
|
+
});
|
|
31
|
+
const code = outcome.kind === 'noActive'
|
|
32
|
+
? 'ORG_PLAN_ATTENTION_NOT_ACTIVE'
|
|
33
|
+
: outcome.kind === 'staleOperation'
|
|
34
|
+
? 'ATTENTION_OPERATION_STALE'
|
|
35
|
+
: outcome.kind === 'legacyUnsupported'
|
|
36
|
+
? 'ATTENTION_LEGACY_UNSUPPORTED'
|
|
37
|
+
: outcome.kind === 'writerCleared'
|
|
38
|
+
? 'ATTENTION_WRITER_CLEARED'
|
|
39
|
+
: 'ATTENTION_WRITER_UNAVAILABLE';
|
|
40
|
+
return reply.code(outcome.kind === 'noActive' ? 404 : 409).send({ code });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -94,6 +94,9 @@ export function parseSupervisedPlan(input) {
|
|
|
94
94
|
return unavailable('MISSING_REQUIRED_FIELD');
|
|
95
95
|
const allSteps = headings;
|
|
96
96
|
const allDone = allSteps.every((step) => step.state === 'DONE');
|
|
97
|
+
const executionComplete = built.every((step) => step.state === 'DONE' &&
|
|
98
|
+
step.reviewStatus === 'REVIEWED' &&
|
|
99
|
+
step.children.every((child) => child.state === 'DONE'));
|
|
97
100
|
const currentStepId = findCurrentStepId(allSteps, allDone);
|
|
98
101
|
if (!currentStepId)
|
|
99
102
|
return unavailable('MALFORMED_ORG');
|
|
@@ -108,6 +111,7 @@ export function parseSupervisedPlan(input) {
|
|
|
108
111
|
totalSteps: allSteps.length,
|
|
109
112
|
doneSteps: allSteps.filter((step) => step.state === 'DONE').length,
|
|
110
113
|
allDone,
|
|
114
|
+
executionComplete,
|
|
111
115
|
currentStepId,
|
|
112
116
|
}),
|
|
113
117
|
};
|
|
@@ -38,12 +38,21 @@ export function registerGetHistory(app, deps) {
|
|
|
38
38
|
retryable: true,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
const
|
|
41
|
+
const controls = deps.autopilotControlTurns?.(session.id) ?? new Map();
|
|
42
|
+
const items = toChatItems(history.turns, controls);
|
|
43
|
+
const auditSource = deps.autopilotAudit?.(session.id, 100) ?? [];
|
|
44
|
+
const boundedAudit = isBoundedAudit(auditSource) ? auditSource : null;
|
|
45
|
+
const auditEvents = boundedAudit
|
|
46
|
+
? boundedAudit.events
|
|
47
|
+
: auditSource;
|
|
48
|
+
const autopilotAudit = toAutopilotAudit(auditEvents);
|
|
42
49
|
const snapshot = {
|
|
43
50
|
items,
|
|
44
|
-
turns: toChatTurns(history.turns),
|
|
51
|
+
turns: toChatTurns(history.turns, controls),
|
|
45
52
|
activeTurnId: history.activeTurnId,
|
|
46
53
|
interactions: deps.interactions?.(session.id) ?? [],
|
|
54
|
+
...(autopilotAudit.length ? { autopilotAudit } : {}),
|
|
55
|
+
...(boundedAudit?.truncated ? { autopilotAuditTruncated: true } : {}),
|
|
47
56
|
baseSequence,
|
|
48
57
|
// Temporary compatibility for clients which have not adopted ChatSnapshot.
|
|
49
58
|
currentSequence: baseSequence,
|
|
@@ -51,3 +60,54 @@ export function registerGetHistory(app, deps) {
|
|
|
51
60
|
return reply.send(snapshot);
|
|
52
61
|
});
|
|
53
62
|
}
|
|
63
|
+
function isBoundedAudit(source) {
|
|
64
|
+
return !Array.isArray(source) && 'events' in source;
|
|
65
|
+
}
|
|
66
|
+
const auditLabels = {
|
|
67
|
+
'autopilot.continuation-scheduled': 'Autopilot scheduled a continuation',
|
|
68
|
+
'autopilot.control-issued': 'Autopilot issued an automatic continuation.',
|
|
69
|
+
'autopilot.turn-started': 'Autopilot continuation started',
|
|
70
|
+
'autopilot.turn-failed': 'Autopilot continuation failed',
|
|
71
|
+
'autopilot.progress-reset': 'Autopilot reset retry progress after the plan changed',
|
|
72
|
+
'org-plan.attention-required': 'Autopilot needs attention',
|
|
73
|
+
'org-plan.attention-resolved': 'Attention resolved',
|
|
74
|
+
};
|
|
75
|
+
function snapshotAuditLabel(payload) {
|
|
76
|
+
if (!payload || typeof payload !== 'object')
|
|
77
|
+
return null;
|
|
78
|
+
const { state, reason } = payload;
|
|
79
|
+
if (state === 'backoff')
|
|
80
|
+
return 'Autopilot is backing off';
|
|
81
|
+
if (state === 'attentionRequired')
|
|
82
|
+
return 'Autopilot needs attention';
|
|
83
|
+
if (state === 'completed')
|
|
84
|
+
return 'Autopilot completed the plan';
|
|
85
|
+
if (state === 'disabled' && reason === 'planRequired')
|
|
86
|
+
return 'Autopilot requires an incomplete supervised plan';
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** Maps journal vocabulary to a deliberately redacted, durable timeline projection. */
|
|
90
|
+
function toAutopilotAudit(events) {
|
|
91
|
+
return events.flatMap((event) => {
|
|
92
|
+
const occurredAt = Date.parse(event.occurredAt);
|
|
93
|
+
if (!Number.isFinite(occurredAt))
|
|
94
|
+
return [];
|
|
95
|
+
const label = event.type === 'org-plan.attention-resolved' &&
|
|
96
|
+
event.payload &&
|
|
97
|
+
typeof event.payload === 'object' &&
|
|
98
|
+
event.payload.outcome === 'failed'
|
|
99
|
+
? 'Attention resolution failed'
|
|
100
|
+
: (auditLabels[event.type] ??
|
|
101
|
+
(event.type === 'autopilot.updated' ? snapshotAuditLabel(event.payload) : null));
|
|
102
|
+
if (!label)
|
|
103
|
+
return [];
|
|
104
|
+
const controlId = event.payload &&
|
|
105
|
+
typeof event.payload === 'object' &&
|
|
106
|
+
typeof event.payload.controlId === 'string'
|
|
107
|
+
? event.payload.controlId
|
|
108
|
+
: undefined;
|
|
109
|
+
return [
|
|
110
|
+
{ id: `audit:${event.sequence}`, label, occurredAt, ...(controlId ? { controlId } : {}) },
|
|
111
|
+
];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -4,21 +4,21 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
/** Canonical grouping preserves the upstream Codex turn identity across reloads. */
|
|
7
|
-
export function toChatTurns(turns) {
|
|
7
|
+
export function toChatTurns(turns, autopilotControlTurns = new Map()) {
|
|
8
8
|
return turns.map((turn, index) => {
|
|
9
9
|
const turnId = turn.id ?? `history-turn-${index}`;
|
|
10
10
|
return {
|
|
11
11
|
id: turnId,
|
|
12
|
-
items: turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turnId)),
|
|
12
|
+
items: turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turnId, autopilotControlTurns)),
|
|
13
13
|
startedAt: turn.startedAt,
|
|
14
14
|
completedAt: turn.completedAt,
|
|
15
15
|
};
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
|
-
export function toChatItems(turns) {
|
|
19
|
-
return turns.flatMap((turn, index) => turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turn.id ?? `history-turn-${index}
|
|
18
|
+
export function toChatItems(turns, autopilotControlTurns = new Map()) {
|
|
19
|
+
return turns.flatMap((turn, index) => turn.items.flatMap((item) => toChatItem(item, occurredAt(item, turn), turn.id ?? `history-turn-${index}`, autopilotControlTurns)));
|
|
20
20
|
}
|
|
21
|
-
function toChatItem(item, timestamp, turnId) {
|
|
21
|
+
function toChatItem(item, timestamp, turnId, autopilotControlTurns) {
|
|
22
22
|
const owner = turnId ? { turnId } : {};
|
|
23
23
|
return [item].flatMap((item) => {
|
|
24
24
|
const id = typeof item.id === 'string' ? item.id : null;
|
|
@@ -26,6 +26,18 @@ function toChatItem(item, timestamp, turnId) {
|
|
|
26
26
|
return [];
|
|
27
27
|
switch (item.type) {
|
|
28
28
|
case 'userMessage': {
|
|
29
|
+
if (typeof item.clientId === 'string' &&
|
|
30
|
+
turnId !== undefined &&
|
|
31
|
+
autopilotControlTurns.get(turnId) === item.clientId)
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
id,
|
|
35
|
+
kind: 'autopilot',
|
|
36
|
+
controlId: item.clientId,
|
|
37
|
+
...owner,
|
|
38
|
+
...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
|
|
39
|
+
},
|
|
40
|
+
];
|
|
29
41
|
const text = Array.isArray(item.content)
|
|
30
42
|
? item.content
|
|
31
43
|
.filter(isRecord)
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { buildResumeCommand } from '../application/resume-command.js';
|
|
7
|
-
export function registerGetSession(app, find) {
|
|
7
|
+
export function registerGetSession(app, find, activity, autopilot) {
|
|
8
8
|
app.get('/api/sessions/:id', async (request, reply) => {
|
|
9
9
|
const session = find(request.params.id);
|
|
10
10
|
return session
|
|
11
11
|
? reply.send({
|
|
12
12
|
...session,
|
|
13
|
+
...(activity ? { agentActivity: activity(session.id) } : {}),
|
|
14
|
+
...(autopilot ? { autopilot: autopilot(session.id) } : {}),
|
|
13
15
|
resumeCommand: session.threadId ? buildResumeCommand(session) : null,
|
|
14
16
|
})
|
|
15
17
|
: reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { isQuizToolResponseForQuiz, parseQuiz } from '../../../../shared/contracts/quiz.js';
|
|
7
|
+
import { isOrgPlanAttentionToolResponse, parseOrgPlanAttention, } from '../../../../shared/contracts/org-plan-attention.js';
|
|
7
8
|
/** Validates the relay-safe subset of Codex's generated server-request responses. */
|
|
8
9
|
export function isValidInteractionResponse(kind, value) {
|
|
9
10
|
if (!isRecord(value))
|
|
10
11
|
return false;
|
|
11
12
|
if (kind === 'userInput')
|
|
12
13
|
return isValidUserInputResponse(value);
|
|
13
|
-
if (kind === 'quiz')
|
|
14
|
+
if (kind === 'quiz' || kind === 'orgPlanAttention')
|
|
14
15
|
return false;
|
|
15
16
|
if (kind === 'permissionsApproval')
|
|
16
17
|
return isRecord(value.permissions) && (value.scope === 'turn' || value.scope === 'session');
|
|
@@ -21,6 +22,9 @@ export function isValidQuizInteractionResponse(payload, value) {
|
|
|
21
22
|
const quiz = parseQuiz(payload);
|
|
22
23
|
return quiz !== null && isQuizToolResponseForQuiz(quiz, value);
|
|
23
24
|
}
|
|
25
|
+
export function isValidOrgPlanAttentionInteractionResponse(payload, value) {
|
|
26
|
+
return parseOrgPlanAttention(payload) !== null && isOrgPlanAttentionToolResponse(value);
|
|
27
|
+
}
|
|
24
28
|
function isValidUserInputResponse(value) {
|
|
25
29
|
if (!isRecord(value.answers))
|
|
26
30
|
return false;
|
|
@@ -7,6 +7,8 @@ import { buildResumeCommand } from '../application/resume-command.js';
|
|
|
7
7
|
export function registerListSessions(app, deps) {
|
|
8
8
|
app.get('/api/sessions', async () => deps.list().map((session) => ({
|
|
9
9
|
...session,
|
|
10
|
+
...(deps.activity ? { agentActivity: deps.activity(session.id) } : {}),
|
|
11
|
+
...(deps.autopilot ? { autopilot: deps.autopilot(session.id) } : {}),
|
|
10
12
|
resumeCommand: session.threadId ? buildResumeCommand(session) : null,
|
|
11
13
|
})));
|
|
12
14
|
}
|
|
@@ -75,6 +75,9 @@ export class RelaySession {
|
|
|
75
75
|
bindThread(value, now) {
|
|
76
76
|
return this.transition({ threadId: threadId(value), state: 'ready' }, 'ThreadBound', now);
|
|
77
77
|
}
|
|
78
|
+
supportsAttentionTool(now) {
|
|
79
|
+
return this.transition({ attentionToolCapability: 'supported' }, 'AttentionToolRegistered', now);
|
|
80
|
+
}
|
|
78
81
|
selectModel(value, now) {
|
|
79
82
|
if (this.value.state !== 'ready')
|
|
80
83
|
throw new DomainError('SESSION_NOT_READY');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client session controller owns foreground and sequence-gap detection.
|
|
3
|
+
* On either signal it calls this idempotent boundary; replaying journal events
|
|
4
|
+
* alone is deliberately not treated as an authoritative activity reconciliation.
|
|
5
|
+
*/
|
|
6
|
+
export function registerRefreshActivity(app, deps) {
|
|
7
|
+
app.post('/api/sessions/:id/activity/refresh', async (request, reply) => {
|
|
8
|
+
const id = request.params.id;
|
|
9
|
+
if (!deps.exists(id))
|
|
10
|
+
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
11
|
+
await deps.refresh(id);
|
|
12
|
+
return reply.code(202).send({ accepted: true });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -20,6 +20,7 @@ import { registerSessionEvents } from './session-events/endpoint.js';
|
|
|
20
20
|
import { registerStartSession } from './start-session/endpoint.js';
|
|
21
21
|
import { registerStartTurn } from './start-turn/endpoint.js';
|
|
22
22
|
import { registerStopSession } from './stop-session/endpoint.js';
|
|
23
|
+
import { registerRefreshActivity } from './refresh-activity/endpoint.js';
|
|
23
24
|
export function registerSessionRoutes(app, deps) {
|
|
24
25
|
const sessions = deps.sessionRoutes;
|
|
25
26
|
if (deps.recentThreads) {
|
|
@@ -46,15 +47,26 @@ export function registerSessionRoutes(app, deps) {
|
|
|
46
47
|
...sessions,
|
|
47
48
|
reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
|
|
48
49
|
});
|
|
49
|
-
registerGetSession(app, sessions.find);
|
|
50
|
+
registerGetSession(app, sessions.find, sessions.agentActivity, sessions.autopilotSnapshot);
|
|
51
|
+
if (sessions.refreshActivity)
|
|
52
|
+
registerRefreshActivity(app, {
|
|
53
|
+
exists: (id) => sessions.find(id) !== null,
|
|
54
|
+
refresh: sessions.refreshActivity,
|
|
55
|
+
});
|
|
50
56
|
if (sessions.list)
|
|
51
|
-
registerListSessions(app, {
|
|
57
|
+
registerListSessions(app, {
|
|
58
|
+
list: sessions.list,
|
|
59
|
+
activity: sessions.agentActivity,
|
|
60
|
+
autopilot: sessions.autopilotSnapshot,
|
|
61
|
+
});
|
|
52
62
|
if (sessions.readHistory)
|
|
53
63
|
registerGetHistory(app, {
|
|
54
64
|
find: sessions.find,
|
|
55
65
|
read: sessions.readHistory,
|
|
56
66
|
currentSequence: sessions.currentSequence ?? (() => 0),
|
|
57
67
|
interactions: deps.interactions?.snapshot,
|
|
68
|
+
autopilotControlTurns: sessions.autopilotControlTurns,
|
|
69
|
+
autopilotAudit: sessions.autopilotAudit,
|
|
58
70
|
});
|
|
59
71
|
if (sessions.startTurn)
|
|
60
72
|
registerStartTurn(app, {
|
|
@@ -80,6 +92,7 @@ export function registerSessionRoutes(app, deps) {
|
|
|
80
92
|
find: sessions.find,
|
|
81
93
|
restore: sessions.restore,
|
|
82
94
|
save: sessions.save,
|
|
95
|
+
ownsWriter: sessions.ownsWriter,
|
|
83
96
|
idempotency: sessions.idempotency,
|
|
84
97
|
});
|
|
85
98
|
if (deps.recentThreads && sessions.promoteRecent)
|
|
@@ -18,8 +18,21 @@ export function registerRespondInteraction(app, deps) {
|
|
|
18
18
|
return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
|
|
19
19
|
if (deps.validate && !deps.validate(id, requestId, value))
|
|
20
20
|
return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const delivery = deps.reply(id, requestId, value);
|
|
22
|
+
if (delivery === false || delivery === 'unavailable')
|
|
23
|
+
return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
|
|
24
|
+
if (delivery === 'cleared') {
|
|
25
|
+
const resolvedAt = deps.now();
|
|
26
|
+
const outcome = 'dismissed';
|
|
27
|
+
if (deps.resolve(id, requestId, resolvedAt, outcome)) {
|
|
28
|
+
deps.resolved?.(id, requestId, resolvedAt, outcome);
|
|
29
|
+
return reply.code(202).send({ accepted: true, resolvedAt, outcome });
|
|
30
|
+
}
|
|
31
|
+
const raced = deps.alreadyResolved?.(id, requestId);
|
|
32
|
+
return raced
|
|
33
|
+
? reply.code(202).send({ accepted: true, ...raced })
|
|
34
|
+
: reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
|
|
35
|
+
}
|
|
23
36
|
const resolvedAt = deps.now();
|
|
24
37
|
const outcome = safeOutcome(value);
|
|
25
38
|
if (!deps.resolve(id, requestId, resolvedAt, outcome))
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { idempotencyKey } from '../../../platform/http/idempotency.js';
|
|
7
|
-
import { RelaySession } from '../model/relay-session.js';
|
|
7
|
+
import { RelaySession, relayOwnsWriter } from '../model/relay-session.js';
|
|
8
8
|
import { canRestore } from './use-case.js';
|
|
9
9
|
export function registerRestoreSession(app, deps) {
|
|
10
10
|
app.post('/api/sessions/:id/restore', async (request, reply) => {
|
|
@@ -17,8 +17,15 @@ export function registerRestoreSession(app, deps) {
|
|
|
17
17
|
const session = deps.find(id);
|
|
18
18
|
if (!session)
|
|
19
19
|
return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
|
|
20
|
-
|
|
20
|
+
// Opening an already live session is a safe no-op. It must not tear down or
|
|
21
|
+
// replace this relay's writer merely because a client retried Open while the
|
|
22
|
+
// session was becoming ready.
|
|
23
|
+
const staleWriter = relayOwnsWriter(session) && deps.ownsWriter?.(id) === false;
|
|
24
|
+
if (!canRestore(session) && !staleWriter) {
|
|
25
|
+
if (relayOwnsWriter(session))
|
|
26
|
+
return reply.send(session);
|
|
21
27
|
return reply.code(409).send({ code: 'SESSION_CANNOT_RESTORE' });
|
|
28
|
+
}
|
|
22
29
|
const recovering = RelaySession.rehydrate(session).beginRecovery(new Date().toISOString()).snapshot;
|
|
23
30
|
deps.save(recovering);
|
|
24
31
|
let restored;
|
|
@@ -28,7 +35,9 @@ export function registerRestoreSession(app, deps) {
|
|
|
28
35
|
catch {
|
|
29
36
|
// The pre-I/O recovery marker must never strand a saved session: keep
|
|
30
37
|
// its original thread and inactive state available for a later Open.
|
|
31
|
-
deps.save(
|
|
38
|
+
deps.save(staleWriter
|
|
39
|
+
? RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot
|
|
40
|
+
: session);
|
|
32
41
|
return reply.code(502).send({ code: 'RESTORE_FAILED' });
|
|
33
42
|
}
|
|
34
43
|
const response = 'session' in restored
|
|
@@ -40,8 +49,15 @@ export function registerRestoreSession(app, deps) {
|
|
|
40
49
|
}
|
|
41
50
|
: restored;
|
|
42
51
|
deps.save(response);
|
|
52
|
+
const settled = (response.state === 'ready' || response.state === 'turnActive') &&
|
|
53
|
+
deps.ownsWriter &&
|
|
54
|
+
!deps.ownsWriter(id)
|
|
55
|
+
? RelaySession.rehydrate(response).stop(new Date().toISOString()).snapshot
|
|
56
|
+
: response;
|
|
57
|
+
if (settled !== response)
|
|
58
|
+
deps.save(settled);
|
|
43
59
|
if (key)
|
|
44
|
-
deps.idempotency?.put(scope, key, 200, JSON.stringify(
|
|
45
|
-
return reply.send(
|
|
60
|
+
deps.idempotency?.put(scope, key, 200, JSON.stringify(settled));
|
|
61
|
+
return reply.send(settled);
|
|
46
62
|
});
|
|
47
63
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
-
import { applySkillSelectionSnapshot } from '../model/skill-profile.js';
|
|
7
|
+
import { applySkillSelectionSnapshot, isAlwaysAdvertisedSkill } from '../model/skill-profile.js';
|
|
8
8
|
import { SkillProfileError } from '../model/errors.js';
|
|
9
9
|
import { problem } from '../../../platform/http/problem.js';
|
|
10
10
|
const querySchema = z
|
|
@@ -34,9 +34,7 @@ export function registerListAvailableSkills(app, deps) {
|
|
|
34
34
|
: deps.catalog.list(profile.name, workspace.realPath),
|
|
35
35
|
deps.selections.readWorkspaceDefault(workspace.realPath),
|
|
36
36
|
]);
|
|
37
|
-
const skills = project
|
|
38
|
-
? applySkillSelectionSnapshot(discovered.skills, project.skills)
|
|
39
|
-
: discovered.skills;
|
|
37
|
+
const skills = applySkillSelectionSnapshot(discovered.skills, project?.skills);
|
|
40
38
|
return {
|
|
41
39
|
source: project ? 'project' : 'native',
|
|
42
40
|
errors: discovered.errors.map(({ message }) => ({ message })),
|
|
@@ -57,6 +55,7 @@ export function registerListAvailableSkills(app, deps) {
|
|
|
57
55
|
scope: skill.scope,
|
|
58
56
|
nativeEnabled: discovered.skills.find((native) => native.path === skill.path)?.enabled ?? false,
|
|
59
57
|
effectiveEnabled: skill.enabled,
|
|
58
|
+
alwaysAdvertised: isAlwaysAdvertisedSkill(skill),
|
|
60
59
|
})),
|
|
61
60
|
};
|
|
62
61
|
}
|
|
@@ -54,6 +54,10 @@ export const availableSkillSchema = z
|
|
|
54
54
|
scope: z.string().optional(),
|
|
55
55
|
})
|
|
56
56
|
.strict();
|
|
57
|
+
/** Gestalt workflow skills are session infrastructure, not optional profile entries. */
|
|
58
|
+
export function isAlwaysAdvertisedSkill(skill) {
|
|
59
|
+
return skill.name.startsWith('gestalt:');
|
|
60
|
+
}
|
|
57
61
|
const profileDocumentSchema = z
|
|
58
62
|
.object({
|
|
59
63
|
version: z.literal(1),
|
|
@@ -135,7 +139,9 @@ export function createSkillSelection(entries) {
|
|
|
135
139
|
/**
|
|
136
140
|
* Apply an optional complete selection snapshot to a fresh catalog. Selection
|
|
137
141
|
* entry names are never used for matching: canonical paths are the identity.
|
|
138
|
-
*
|
|
142
|
+
* Gestalt-provider skills are always enabled so update-added infrastructure
|
|
143
|
+
* cannot disappear from a session catalog. Other skills retain native state
|
|
144
|
+
* when no snapshot exists.
|
|
139
145
|
*/
|
|
140
146
|
export function applySkillSelectionSnapshot(discovered, selection) {
|
|
141
147
|
const catalog = discovered.map((skill) => {
|
|
@@ -145,11 +151,13 @@ export function applySkillSelectionSnapshot(discovered, selection) {
|
|
|
145
151
|
}
|
|
146
152
|
return { ...parsed.data, path: canonicalSkillPath(parsed.data.path) };
|
|
147
153
|
});
|
|
148
|
-
if (selection === undefined)
|
|
149
|
-
return catalog;
|
|
150
|
-
}
|
|
154
|
+
if (selection === undefined)
|
|
155
|
+
return catalog.map((skill) => isAlwaysAdvertisedSkill(skill) ? { ...skill, enabled: true } : skill);
|
|
151
156
|
const enabledByPath = new Map(createSkillSelection(selection).map((entry) => [entry.path, entry.enabled]));
|
|
152
|
-
return catalog.map((skill) => ({
|
|
157
|
+
return catalog.map((skill) => ({
|
|
158
|
+
...skill,
|
|
159
|
+
enabled: isAlwaysAdvertisedSkill(skill) ? true : (enabledByPath.get(skill.path) ?? false),
|
|
160
|
+
}));
|
|
153
161
|
}
|
|
154
162
|
/** Explicit session selection takes precedence over a workspace default. */
|
|
155
163
|
export function selectEffectiveSkillSelection(input) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const id = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : undefined;
|
|
7
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value)
|
|
8
|
+
? value
|
|
9
|
+
: undefined;
|
|
10
|
+
/** Decodes only bounded app-server evidence; unsupported shapes deliberately become no fact. */
|
|
11
|
+
export function decodeAgentActivityFact(sessionId, occurredAt, input) {
|
|
12
|
+
const params = record(input.params);
|
|
13
|
+
if (!params)
|
|
14
|
+
return null;
|
|
15
|
+
if (input.method === 'thread/started') {
|
|
16
|
+
const thread = record(params.thread);
|
|
17
|
+
const status = record(thread?.status);
|
|
18
|
+
return id(thread?.id)
|
|
19
|
+
? {
|
|
20
|
+
sessionId,
|
|
21
|
+
occurredAt,
|
|
22
|
+
kind: 'threadStarted',
|
|
23
|
+
threadId: id(thread?.id),
|
|
24
|
+
...(typeof status?.type === 'string' ? { status: status.type } : {}),
|
|
25
|
+
}
|
|
26
|
+
: null;
|
|
27
|
+
}
|
|
28
|
+
if (input.method === 'thread/status/changed') {
|
|
29
|
+
const status = record(params.status);
|
|
30
|
+
return id(params.threadId) && typeof status?.type === 'string' && status.type.length <= 64
|
|
31
|
+
? {
|
|
32
|
+
sessionId,
|
|
33
|
+
occurredAt,
|
|
34
|
+
kind: 'threadStatus',
|
|
35
|
+
threadId: id(params.threadId),
|
|
36
|
+
status: status.type,
|
|
37
|
+
}
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
if (input.method === 'turn/started')
|
|
41
|
+
return id(params.threadId)
|
|
42
|
+
? {
|
|
43
|
+
sessionId,
|
|
44
|
+
occurredAt,
|
|
45
|
+
kind: 'turnStarted',
|
|
46
|
+
threadId: id(params.threadId),
|
|
47
|
+
turnId: id(record(params.turn)?.id),
|
|
48
|
+
}
|
|
49
|
+
: null;
|
|
50
|
+
if (input.method === 'turn/completed')
|
|
51
|
+
return id(record(params.turn)?.id)
|
|
52
|
+
? {
|
|
53
|
+
sessionId,
|
|
54
|
+
occurredAt,
|
|
55
|
+
kind: 'turnCompleted',
|
|
56
|
+
threadId: id(params.threadId),
|
|
57
|
+
turnId: id(record(params.turn)?.id),
|
|
58
|
+
status: typeof record(params.turn)?.status === 'string'
|
|
59
|
+
? record(params.turn)?.status
|
|
60
|
+
: undefined,
|
|
61
|
+
}
|
|
62
|
+
: null;
|
|
63
|
+
if (input.method === 'item/started' || input.method === 'item/completed') {
|
|
64
|
+
const item = record(params.item);
|
|
65
|
+
if (item?.type !== 'collabToolCall')
|
|
66
|
+
return null;
|
|
67
|
+
const action = typeof item.tool === 'string' &&
|
|
68
|
+
['spawn_agent', 'send_input', 'resume_agent', 'wait', 'close_agent'].includes(item.tool)
|
|
69
|
+
? item.tool
|
|
70
|
+
: undefined;
|
|
71
|
+
const child = id(item.receiverThreadId) ?? id(item.newThreadId);
|
|
72
|
+
const itemStatus = typeof item.status === 'string' && item.status.length <= 64 ? item.status : undefined;
|
|
73
|
+
return {
|
|
74
|
+
sessionId,
|
|
75
|
+
occurredAt,
|
|
76
|
+
kind: 'collaboration',
|
|
77
|
+
...(child ? { childId: child, childThreadId: child } : {}),
|
|
78
|
+
...(id(item.senderThreadId) ? { threadId: id(item.senderThreadId) } : {}),
|
|
79
|
+
...(typeof item.agentStatus === 'string' && item.agentStatus.length <= 64
|
|
80
|
+
? { childStatus: item.agentStatus }
|
|
81
|
+
: itemStatus
|
|
82
|
+
? { childStatus: itemStatus }
|
|
83
|
+
: {}),
|
|
84
|
+
...(action ? { collaborationAction: action } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -4,14 +4,22 @@
|
|
|
4
4
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
*/
|
|
6
6
|
import { GESTALT_QUIZ_TOOL_NAME, parseQuiz } from '../../../shared/contracts/quiz.js';
|
|
7
|
+
import { GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME, parseOrgPlanAttention, } from '../../../shared/contracts/org-plan-attention.js';
|
|
7
8
|
export function toPendingInteraction(input) {
|
|
8
9
|
if (!Number.isSafeInteger(input.id) || !isRecord(input.params))
|
|
9
10
|
return null;
|
|
10
11
|
if (input.method === 'item/tool/call') {
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
if (input.params.tool === GESTALT_QUIZ_TOOL_NAME) {
|
|
13
|
+
const quiz = parseQuiz(input.params.arguments);
|
|
14
|
+
return quiz ? { requestId: String(input.id), kind: 'quiz', payload: quiz } : null;
|
|
15
|
+
}
|
|
16
|
+
if (input.params.tool === GESTALT_ORG_PLAN_ATTENTION_TOOL_NAME) {
|
|
17
|
+
const attention = parseOrgPlanAttention(input.params.arguments);
|
|
18
|
+
return attention
|
|
19
|
+
? { requestId: String(input.id), kind: 'orgPlanAttention', payload: attention }
|
|
20
|
+
: null;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
15
23
|
}
|
|
16
24
|
const kind = {
|
|
17
25
|
'item/commandExecution/requestApproval': 'commandApproval',
|
|
@@ -21,6 +29,15 @@ export function toPendingInteraction(input) {
|
|
|
21
29
|
}[input.method];
|
|
22
30
|
return kind ? { requestId: String(input.id), kind, payload: input.params } : null;
|
|
23
31
|
}
|
|
32
|
+
export function resolvedServerRequestId(input) {
|
|
33
|
+
if (input.method !== 'serverRequest/resolved' || !isRecord(input.params))
|
|
34
|
+
return null;
|
|
35
|
+
const requestId = input.params.requestId;
|
|
36
|
+
if (!((typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 256) ||
|
|
37
|
+
(typeof requestId === 'number' && Number.isSafeInteger(requestId))))
|
|
38
|
+
return null;
|
|
39
|
+
return String(requestId);
|
|
40
|
+
}
|
|
24
41
|
function isRecord(value) {
|
|
25
42
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
26
43
|
}
|