@xfey/tutti 0.1.105 → 0.1.107
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 +3 -2
- package/dist/approvals/decision-command.d.ts +124 -0
- package/dist/approvals/decision-command.js +20 -0
- package/dist/approvals/index.d.ts +1 -0
- package/dist/approvals/index.js +1 -0
- package/dist/approvals/manager.d.ts +3 -1
- package/dist/approvals/manager.js +9 -0
- package/dist/collaboration-ingestion/index.d.ts +3 -0
- package/dist/collaboration-ingestion/index.js +3 -0
- package/dist/collaboration-ingestion/managed-reference-snapshots.d.ts +27 -0
- package/dist/collaboration-ingestion/managed-reference-snapshots.js +45 -0
- package/dist/collaboration-ingestion/managed-reference-sources.d.ts +20 -0
- package/dist/collaboration-ingestion/managed-reference-sources.js +250 -0
- package/dist/collaboration-ingestion/reference-attachments.d.ts +22 -0
- package/dist/collaboration-ingestion/reference-attachments.js +132 -0
- package/dist/collaboration-ingestion/reference-files.d.ts +3 -2
- package/dist/collaboration-ingestion/reference-files.js +1 -0
- package/dist/control-plane/procedure-logging.d.ts +1 -0
- package/dist/control-plane/procedure-logging.js +16 -5
- package/dist/control-plane/reference-summary-refresh.js +2 -2
- package/dist/control-plane/workflows/openai.js +1 -0
- package/dist/platform-outbound/coordinator.d.ts +5 -2
- package/dist/platform-outbound/coordinator.js +95 -2
- package/dist/platform-outbound/repository.d.ts +17 -0
- package/dist/platform-outbound/repository.js +190 -5
- package/dist/platform-outbound/types.d.ts +13 -3
- package/dist/platform-outbound/worker.js +30 -14
- package/dist/providers/openai/app-server/read-only-procedure.d.ts +10 -2
- package/dist/providers/openai/app-server/read-only-procedure.js +65 -15
- package/dist/server-shell/cli/host-lifecycle.js +3 -0
- package/dist/server-shell/cli/host-server-runtime.d.ts +4 -1
- package/dist/server-shell/cli/host-server-runtime.js +128 -40
- package/dist/server-shell/cli/relay-registration.js +3 -0
- package/dist/server-shell/http/host-tunnel.d.ts +4 -1
- package/dist/server-shell/http/host-tunnel.js +174 -3
- package/dist/server-shell/http/routes/project-api/approval-routes.js +13 -20
- package/dist/server-shell/http/routes/project-api/reference-files-routes.js +19 -2
- package/dist/server-shell/http/routes/project-api/staged-uploads.d.ts +6 -0
- package/dist/server-shell/http/routes/project-api/staged-uploads.js +9 -2
- package/dist/workspace-ops/index.d.ts +2 -2
- package/dist/workspace-ops/index.js +1 -1
- package/dist/workspace-ops/reference-files.d.ts +14 -1
- package/dist/workspace-ops/reference-files.js +167 -2
- package/dist/workspace-ops/types.d.ts +24 -0
- package/migrations/0018_platform_outbound_events.sql +111 -0
- package/migrations/0019_platform_outbound_card_content.sql +76 -0
- package/migrations/0020_managed_reference_sources.sql +34 -0
- package/migrations/README.md +4 -1
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +5 -2
- package/node_modules/@tutti/relay-client/dist/host-control.js +3 -0
- package/node_modules/@tutti/shared/dist/schemas/api/feishu-documents.d.ts +41 -0
- package/node_modules/@tutti/shared/dist/schemas/api/feishu-documents.js +40 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +3 -2
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +2 -1
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +37 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +45 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-documents.d.ts +38 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-documents.js +31 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-user-credentials.d.ts +22 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/feishu-user-credentials.js +37 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +2 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.js +2 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1005 -49
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +434 -14
- package/package.json +1 -1
- package/prompts/skills/read-references/README.md +1 -1
- package/prompts/skills/read-references/SKILL.md +4 -2
- package/web/assets/{homepage-motion-scene-BnRTgItL.js → homepage-motion-scene-CFs5rMWq.js} +1 -1
- package/web/assets/index-BmiLZmaV.js +69 -0
- package/web/assets/{index-CTMOjCgU.css → index-DvfZ57Nl.css} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-CmzEr4-V.js +0 -69
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { INTEGRATION_REFERENCE_ATTACHMENT_TUNNEL_PATH, isTrustedReferenceAttachmentIngestionCommand, } from "@tutti/shared/schemas/internal";
|
|
3
|
+
import { decideCommandIdempotencyReplay, readCommandIdempotencyRecord, } from "../server-shell/command-idempotency/index.js";
|
|
4
|
+
import { ingestReferenceFile, REFERENCE_FILE_INGESTION_COMMAND_NAME, REFERENCE_FILE_INGESTION_TARGET_REF, } from "./reference-files.js";
|
|
5
|
+
export const TRUSTED_INTEGRATION_REFERENCE_ATTACHMENT_PATH = INTEGRATION_REFERENCE_ATTACHMENT_TUNNEL_PATH;
|
|
6
|
+
function referencePayload(command) {
|
|
7
|
+
return {
|
|
8
|
+
upload_id: command.staged_upload.upload_id,
|
|
9
|
+
file_name: command.staged_upload.file_name,
|
|
10
|
+
size_bytes: command.staged_upload.size_bytes,
|
|
11
|
+
sha256: command.staged_upload.sha256,
|
|
12
|
+
...(command.staged_upload.media_type === undefined
|
|
13
|
+
? {}
|
|
14
|
+
: { media_type: command.staged_upload.media_type }),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function readExistingAttachmentMessage(db, command) {
|
|
18
|
+
const installation = command.source.locator.conversation.installation;
|
|
19
|
+
return (db
|
|
20
|
+
.prepare(`
|
|
21
|
+
SELECT message_id
|
|
22
|
+
FROM external_source_mappings
|
|
23
|
+
WHERE platform = ?
|
|
24
|
+
AND installation_ref = ?
|
|
25
|
+
AND external_message_ref = ?
|
|
26
|
+
`)
|
|
27
|
+
.get(installation.platform, installation.installation_ref, command.source.locator.message_ref)
|
|
28
|
+
?.message_id ?? null);
|
|
29
|
+
}
|
|
30
|
+
function insertAttachmentEvidence(tx, command, _file, message) {
|
|
31
|
+
const source = command.source;
|
|
32
|
+
const installation = source.locator.conversation.installation;
|
|
33
|
+
const contentSha256 = `sha256:${createHash("sha256")
|
|
34
|
+
.update(source.attachment.attachment_ref, "utf8")
|
|
35
|
+
.digest("hex")}`;
|
|
36
|
+
tx.prepare(`
|
|
37
|
+
INSERT INTO external_source_mappings (
|
|
38
|
+
platform, installation_ref, conversation_ref, thread_ref, parent_message_ref,
|
|
39
|
+
external_message_ref, message_id, binding_ref, receipt_key, external_subject_ref,
|
|
40
|
+
author_account_ref, author_display_name, external_created_at, accepted_at,
|
|
41
|
+
initial_revision_ref, initial_observed_at, content_sha256, adapter_version,
|
|
42
|
+
latest_revision_ref, latest_external_updated_at
|
|
43
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
44
|
+
`).run(installation.platform, installation.installation_ref, source.locator.conversation.conversation_ref, source.locator.conversation.thread_ref ?? null, source.locator.conversation.parent_message_ref ?? null, source.locator.message_ref, message.id, command.binding_ref, command.receipt.receipt_key, source.author.external_subject.subject_ref, command.actor.account_ref, command.actor.display_name, source.external_created_at, message.created_at, command.receipt.external_event_ref, source.observed_at, contentSha256, source.adapter_version, command.receipt.external_event_ref, source.external_created_at);
|
|
45
|
+
tx.prepare(`
|
|
46
|
+
INSERT INTO external_source_revision_observations (
|
|
47
|
+
platform, installation_ref, external_message_ref, revision_ref, observation_kind,
|
|
48
|
+
content_sha256, external_updated_at, observed_at, adapter_version
|
|
49
|
+
) VALUES (?, ?, ?, ?, 'created', ?, ?, ?, ?)
|
|
50
|
+
`).run(installation.platform, installation.installation_ref, source.locator.message_ref, command.receipt.external_event_ref, contentSha256, source.external_created_at, source.observed_at, source.adapter_version);
|
|
51
|
+
}
|
|
52
|
+
export async function ingestTrustedReferenceAttachment(options) {
|
|
53
|
+
if (!isTrustedReferenceAttachmentIngestionCommand(options.command)) {
|
|
54
|
+
return { kind: "invalid", reason_code: "trusted_attachment_command_invalid" };
|
|
55
|
+
}
|
|
56
|
+
const existingMessageId = readExistingAttachmentMessage(options.store.db, options.command);
|
|
57
|
+
if (existingMessageId !== null) {
|
|
58
|
+
return { kind: "duplicate", message_id: existingMessageId };
|
|
59
|
+
}
|
|
60
|
+
const commandId = options.command.command_id;
|
|
61
|
+
const payload = referencePayload(options.command);
|
|
62
|
+
const replay = decideCommandIdempotencyReplay(readCommandIdempotencyRecord(options.store.db, commandId), {
|
|
63
|
+
command_name: REFERENCE_FILE_INGESTION_COMMAND_NAME,
|
|
64
|
+
target_ref: REFERENCE_FILE_INGESTION_TARGET_REF,
|
|
65
|
+
payload,
|
|
66
|
+
});
|
|
67
|
+
if (replay.kind === "mismatch") {
|
|
68
|
+
return { kind: "invalid", reason_code: "attachment_command_replay_mismatch" };
|
|
69
|
+
}
|
|
70
|
+
let staged;
|
|
71
|
+
try {
|
|
72
|
+
if (replay.kind === "start") {
|
|
73
|
+
try {
|
|
74
|
+
staged = await options.resolveContent();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return {
|
|
78
|
+
kind: "host_unavailable",
|
|
79
|
+
retryable: true,
|
|
80
|
+
reason_code: "attachment_stage_unavailable",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const response = await ingestReferenceFile({
|
|
85
|
+
store: options.store,
|
|
86
|
+
workspaceRoot: options.workspaceRoot,
|
|
87
|
+
events: options.events,
|
|
88
|
+
commandId,
|
|
89
|
+
payload,
|
|
90
|
+
content: staged?.content,
|
|
91
|
+
author: {
|
|
92
|
+
account_ref: options.command.actor.account_ref,
|
|
93
|
+
display_name: options.command.actor.display_name,
|
|
94
|
+
},
|
|
95
|
+
afterCreate: (tx, file, message) => insertAttachmentEvidence(tx, options.command, file, message),
|
|
96
|
+
...(options.now === undefined ? {} : { now: options.now }),
|
|
97
|
+
afterCommit: async () => {
|
|
98
|
+
if (staged !== undefined) {
|
|
99
|
+
await staged.complete();
|
|
100
|
+
staged = undefined;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
...(options.scratchpadSource === undefined
|
|
104
|
+
? {}
|
|
105
|
+
: { scratchpadSource: options.scratchpadSource }),
|
|
106
|
+
...(options.referenceSummaries === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: { referenceSummaries: options.referenceSummaries }),
|
|
109
|
+
});
|
|
110
|
+
if (response.disposition.kind === "interrupted") {
|
|
111
|
+
return {
|
|
112
|
+
kind: "host_unavailable",
|
|
113
|
+
retryable: true,
|
|
114
|
+
reason_code: "command_interrupted",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const messageId = response.result?.message.id;
|
|
118
|
+
if (messageId === undefined) {
|
|
119
|
+
return { kind: "invalid", reason_code: "attachment_message_identity_missing" };
|
|
120
|
+
}
|
|
121
|
+
return replay.kind === "start"
|
|
122
|
+
? { kind: "created", message_id: messageId }
|
|
123
|
+
: { kind: "duplicate", message_id: messageId };
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
if (staged !== undefined) {
|
|
127
|
+
await staged.abort();
|
|
128
|
+
}
|
|
129
|
+
return { kind: "invalid", reason_code: "reference_attachment_rejected" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=reference-attachments.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IdempotencyKey } from "@tutti/shared/ids";
|
|
2
|
-
import type { ReferenceFileProjection, UploadReferenceFilePayload, UploadReferenceFileResponse } from "@tutti/shared/schemas/api";
|
|
3
|
-
import type { HostProjectStore } from "../store/index.js";
|
|
2
|
+
import type { MessageProjection, ReferenceFileProjection, UploadReferenceFilePayload, UploadReferenceFileResponse } from "@tutti/shared/schemas/api";
|
|
3
|
+
import type { HostProjectStore, SqliteDatabase } from "../store/index.js";
|
|
4
4
|
import { readReferenceSummaryIndex, type ViewerTreeEntry } from "../workspace-ops/index.js";
|
|
5
5
|
import type { WorkspaceEventBus } from "../server-shell/http/workspace-events.js";
|
|
6
6
|
import type { ReferenceSummaryStarter, ScratchpadSourceNotifier, TrustedCollaborationAuthor } from "./types.js";
|
|
@@ -20,6 +20,7 @@ export type IngestReferenceFileOptions = {
|
|
|
20
20
|
payload: UploadReferenceFilePayload;
|
|
21
21
|
content: Buffer | undefined;
|
|
22
22
|
author: TrustedCollaborationAuthor;
|
|
23
|
+
afterCreate?: (tx: SqliteDatabase, file: ReferenceFileProjection, message: MessageProjection) => void;
|
|
23
24
|
afterCommit?: () => Promise<void>;
|
|
24
25
|
scratchpadSource?: ScratchpadSourceNotifier;
|
|
25
26
|
referenceSummaries?: ReferenceSummaryStarter;
|
|
@@ -121,6 +121,7 @@ export async function ingestReferenceFile(options) {
|
|
|
121
121
|
actor: { kind: "human", ref: options.author.account_ref },
|
|
122
122
|
occurred_at: message.created_at,
|
|
123
123
|
});
|
|
124
|
+
options.afterCreate?.(tx, file, message);
|
|
124
125
|
return {
|
|
125
126
|
disposition: { kind: "created" },
|
|
126
127
|
result: { file, message, index_path: uploaded.index_path },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ActivityRef, WorkflowInvocationRef } from "@tutti/shared/ids";
|
|
2
2
|
import type { WorkflowKind } from "@tutti/shared/domain";
|
|
3
3
|
import type { ControlPlaneLogger } from "./types.js";
|
|
4
|
+
export declare function procedureExecutionErrorFields(error: unknown): Record<string, unknown>;
|
|
4
5
|
export declare function logProcedureExecutionError(input: {
|
|
5
6
|
logger: ControlPlaneLogger | undefined;
|
|
6
7
|
workflowKind: WorkflowKind;
|
|
@@ -8,6 +8,12 @@ const SAFE_DETAIL_KEYS = [
|
|
|
8
8
|
"notification_count",
|
|
9
9
|
"server_request_methods",
|
|
10
10
|
"notification_methods",
|
|
11
|
+
"output_parse_reason",
|
|
12
|
+
"agent_output_chars",
|
|
13
|
+
"json_candidate_count",
|
|
14
|
+
"agent_message_count",
|
|
15
|
+
"completed_tool_count",
|
|
16
|
+
"file_tool_count",
|
|
11
17
|
];
|
|
12
18
|
function isRecord(value) {
|
|
13
19
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -37,16 +43,21 @@ function errorCode(error) {
|
|
|
37
43
|
}
|
|
38
44
|
return error.code;
|
|
39
45
|
}
|
|
46
|
+
export function procedureExecutionErrorFields(error) {
|
|
47
|
+
const code = errorCode(error);
|
|
48
|
+
const details = errorDetails(error);
|
|
49
|
+
return {
|
|
50
|
+
error_name: error instanceof Error ? error.name : "NonError",
|
|
51
|
+
...(code === undefined ? {} : { error_code: code }),
|
|
52
|
+
...(details === undefined ? {} : { error_details: details }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
40
55
|
export function logProcedureExecutionError(input) {
|
|
41
56
|
input.logger?.info({
|
|
42
57
|
activity_ref: input.activityRef,
|
|
43
58
|
workflow_ref: input.workflowRef,
|
|
44
59
|
workflow_kind: input.workflowKind,
|
|
45
|
-
|
|
46
|
-
...(errorCode(input.error) === undefined ? {} : { error_code: errorCode(input.error) }),
|
|
47
|
-
...(errorDetails(input.error) === undefined
|
|
48
|
-
? {}
|
|
49
|
-
: { error_details: errorDetails(input.error) }),
|
|
60
|
+
...procedureExecutionErrorFields(input.error),
|
|
50
61
|
}, "Procedure execution error");
|
|
51
62
|
}
|
|
52
63
|
//# sourceMappingURL=procedure-logging.js.map
|
|
@@ -2,7 +2,7 @@ import { createWorkflowInvocationRef } from "@tutti/shared/ids";
|
|
|
2
2
|
import { recordProjectTimelineEvent } from "../project-timeline/index.js";
|
|
3
3
|
import { withHostStoreTransaction } from "../store/index.js";
|
|
4
4
|
import { WorkspaceOpsError, readReferenceSummaryTargets, updateReferenceSummaries, } from "../workspace-ops/index.js";
|
|
5
|
-
import { logProcedureExecutionError } from "./procedure-logging.js";
|
|
5
|
+
import { logProcedureExecutionError, procedureExecutionErrorFields } from "./procedure-logging.js";
|
|
6
6
|
import { enqueueControlPlaneIntent, readControlPlaneIntentByDedupeKey, settlePendingControlPlaneIntent, } from "./dispatch-intents.js";
|
|
7
7
|
const REFERENCE_SUMMARY_CONCURRENCY = 1;
|
|
8
8
|
const REFERENCE_SUMMARY_MAX_ATTEMPTS = 2;
|
|
@@ -255,7 +255,7 @@ async function summarizeReferenceTarget(options) {
|
|
|
255
255
|
path: options.target.path,
|
|
256
256
|
attempt,
|
|
257
257
|
retrying: attempt < REFERENCE_SUMMARY_MAX_ATTEMPTS,
|
|
258
|
-
|
|
258
|
+
...procedureExecutionErrorFields(error),
|
|
259
259
|
}, attempt < REFERENCE_SUMMARY_MAX_ATTEMPTS
|
|
260
260
|
? "Reference summary target retrying"
|
|
261
261
|
: "Reference summary target failed");
|
|
@@ -447,6 +447,7 @@ export function createOpenAiProcedureWorkflowRunner(options) {
|
|
|
447
447
|
appServerCwd: workspace.cwd,
|
|
448
448
|
validateOutput: isReferenceFileSummaryStructuredOutput,
|
|
449
449
|
readablePaths: [workspace.cwd],
|
|
450
|
+
requireFileToolUse: true,
|
|
450
451
|
networkAccess: false,
|
|
451
452
|
webSearchMode: "disabled",
|
|
452
453
|
});
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import type { MessageProjection } from "@tutti/shared/schemas/api";
|
|
1
|
+
import type { ApprovalRequestProjection, MessageProjection } from "@tutti/shared/schemas/api";
|
|
2
2
|
import type { SqliteDatabase } from "../store/index.js";
|
|
3
|
+
import type { HostProjectStore } from "../store/index.js";
|
|
3
4
|
import type { HostPlatformOutboundLogger } from "./types.js";
|
|
4
5
|
import type { HostPlatformOutboundWorker } from "./worker.js";
|
|
5
6
|
export declare class HostPlatformOutboundCoordinator {
|
|
7
|
+
private readonly store;
|
|
6
8
|
private readonly worker;
|
|
7
9
|
private readonly logger;
|
|
8
10
|
private readonly now;
|
|
9
|
-
constructor(worker: HostPlatformOutboundWorker, logger: HostPlatformOutboundLogger, now?: () => Date);
|
|
11
|
+
constructor(store: HostProjectStore, worker: HostPlatformOutboundWorker, logger: HostPlatformOutboundLogger, now?: () => Date);
|
|
10
12
|
captureAgentResponse(tx: SqliteDatabase, input: {
|
|
11
13
|
triggerMessage: MessageProjection;
|
|
12
14
|
responseMessage: MessageProjection;
|
|
13
15
|
}): (() => void) | undefined;
|
|
16
|
+
captureApprovalRequest(request: ApprovalRequestProjection): void;
|
|
14
17
|
start(): void;
|
|
15
18
|
stop(): Promise<void>;
|
|
16
19
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createPlatformOutboundIntent, createPlatformStatusOutboundIntent } from "./repository.js";
|
|
2
3
|
export class HostPlatformOutboundCoordinator {
|
|
4
|
+
store;
|
|
3
5
|
worker;
|
|
4
6
|
logger;
|
|
5
7
|
now;
|
|
6
|
-
constructor(worker, logger, now = () => new Date()) {
|
|
8
|
+
constructor(store, worker, logger, now = () => new Date()) {
|
|
9
|
+
this.store = store;
|
|
7
10
|
this.worker = worker;
|
|
8
11
|
this.logger = logger;
|
|
9
12
|
this.now = now;
|
|
@@ -38,6 +41,96 @@ export class HostPlatformOutboundCoordinator {
|
|
|
38
41
|
this.worker.wake();
|
|
39
42
|
};
|
|
40
43
|
}
|
|
44
|
+
captureApprovalRequest(request) {
|
|
45
|
+
const bounded = (value, maximum) => [...value.trim()].slice(0, maximum).join("");
|
|
46
|
+
const title = bounded(request.title, 512);
|
|
47
|
+
const summary = bounded(request.summary, 4_000);
|
|
48
|
+
const reason = request.reason === undefined ? undefined : bounded(request.reason, 2_000);
|
|
49
|
+
const decisions = request.decisions.slice(0, 8).map((decision) => ({
|
|
50
|
+
decision_id: bounded(decision.decision_id, 256),
|
|
51
|
+
kind: decision.kind,
|
|
52
|
+
label: bounded(decision.label, 256),
|
|
53
|
+
tone: decision.tone,
|
|
54
|
+
}));
|
|
55
|
+
const revisionSource = JSON.stringify({
|
|
56
|
+
approval_id: request.approval_id,
|
|
57
|
+
title,
|
|
58
|
+
summary,
|
|
59
|
+
reason,
|
|
60
|
+
requested_at: request.requested_at,
|
|
61
|
+
expires_at: request.expires_at,
|
|
62
|
+
decisions,
|
|
63
|
+
});
|
|
64
|
+
const cardRevision = `cardrev_${createHash("sha256")
|
|
65
|
+
.update(revisionSource, "utf8")
|
|
66
|
+
.digest("hex")
|
|
67
|
+
.slice(0, 32)}`;
|
|
68
|
+
const content = {
|
|
69
|
+
kind: "approval_request",
|
|
70
|
+
approval_id: request.approval_id,
|
|
71
|
+
card_revision: cardRevision,
|
|
72
|
+
title,
|
|
73
|
+
summary,
|
|
74
|
+
...(reason === undefined ? {} : { reason }),
|
|
75
|
+
requested_at: request.requested_at,
|
|
76
|
+
...(request.expires_at === undefined ? {} : { expires_at: request.expires_at }),
|
|
77
|
+
decisions,
|
|
78
|
+
};
|
|
79
|
+
const eventKey = `approval_request:${request.approval_id}:opened`;
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = this.store.db.transaction(() => createPlatformStatusOutboundIntent(this.store.db, {
|
|
83
|
+
eventKind: "needs_input",
|
|
84
|
+
eventKey,
|
|
85
|
+
subjectKind: "approval_request",
|
|
86
|
+
subjectRef: request.approval_id,
|
|
87
|
+
targetKind: "project",
|
|
88
|
+
content,
|
|
89
|
+
now: this.now,
|
|
90
|
+
}))();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
try {
|
|
94
|
+
this.logger.warn({
|
|
95
|
+
stage: "platform_outbound_capture",
|
|
96
|
+
disposition: "failed",
|
|
97
|
+
event_kind: "needs_input",
|
|
98
|
+
event_key: eventKey,
|
|
99
|
+
subject_kind: "approval_request",
|
|
100
|
+
subject_ref: request.approval_id,
|
|
101
|
+
target_kind: "project",
|
|
102
|
+
error_code: "approval_outbound_capture_failed",
|
|
103
|
+
}, "Platform outbound approval intent capture failed");
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Approval correctness remains independent from both capture and logging failures.
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (result === null) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
this.logger.info({
|
|
115
|
+
stage: "platform_outbound_capture",
|
|
116
|
+
disposition: result.created ? "created" : "duplicate",
|
|
117
|
+
outbound_intent_ref: result.intent.intent_ref,
|
|
118
|
+
event_kind: result.intent.event_kind,
|
|
119
|
+
event_key: result.intent.event_key,
|
|
120
|
+
subject_kind: result.intent.subject_kind,
|
|
121
|
+
subject_ref: result.intent.subject_ref,
|
|
122
|
+
target_kind: result.intent.target_kind,
|
|
123
|
+
installation_ref: result.intent.installation_ref,
|
|
124
|
+
binding_ref: result.intent.binding_ref,
|
|
125
|
+
}, result.created
|
|
126
|
+
? "Platform outbound approval intent created"
|
|
127
|
+
: "Platform outbound approval intent already exists");
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// The pending approval and durable intent remain authoritative when logging fails.
|
|
131
|
+
}
|
|
132
|
+
this.worker.wake();
|
|
133
|
+
}
|
|
41
134
|
start() {
|
|
42
135
|
this.worker.start();
|
|
43
136
|
}
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
import { type PlatformOutboundContent } from "@tutti/shared/schemas/internal";
|
|
1
2
|
import type { SqliteDatabase } from "../store/index.js";
|
|
2
3
|
import type { HostPlatformOutboundIntentRecord, HostPlatformOutboundWorkItem } from "./types.js";
|
|
4
|
+
export declare function initializePlatformOutboundEventCapture(db: SqliteDatabase, now?: () => Date): string;
|
|
5
|
+
export declare function createPlatformStatusOutboundIntent(tx: SqliteDatabase, input: {
|
|
6
|
+
eventKind: "needs_input" | "task_completed" | "task_failed";
|
|
7
|
+
eventKey: string;
|
|
8
|
+
subjectKind: "clarification_round" | "approval_request" | "task_result";
|
|
9
|
+
subjectRef: string;
|
|
10
|
+
targetKind: "project" | "work";
|
|
11
|
+
canonicalMessageId?: string;
|
|
12
|
+
contentText?: string;
|
|
13
|
+
content?: PlatformOutboundContent;
|
|
14
|
+
now?: () => Date;
|
|
15
|
+
}): {
|
|
16
|
+
created: boolean;
|
|
17
|
+
intent: HostPlatformOutboundIntentRecord;
|
|
18
|
+
} | null;
|
|
19
|
+
export declare function materializePlatformStatusOutboundIntents(db: SqliteDatabase, now?: () => Date): HostPlatformOutboundIntentRecord[];
|
|
3
20
|
export declare function createPlatformOutboundIntent(tx: SqliteDatabase, input: {
|
|
4
21
|
triggerMessageId: string;
|
|
5
22
|
canonicalMessageId: string;
|
|
@@ -1,4 +1,170 @@
|
|
|
1
1
|
import { createIdempotencyKey } from "@tutti/shared/ids";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { PlatformOutboundContentSchema, } from "@tutti/shared/schemas/internal";
|
|
4
|
+
const MAX_PLATFORM_OUTBOUND_TEXT_LENGTH = 4_000;
|
|
5
|
+
function boundedPlatformOutboundText(value) {
|
|
6
|
+
const characters = [...value.trim()];
|
|
7
|
+
if (characters.length <= MAX_PLATFORM_OUTBOUND_TEXT_LENGTH) {
|
|
8
|
+
return characters.join("");
|
|
9
|
+
}
|
|
10
|
+
return `${characters.slice(0, MAX_PLATFORM_OUTBOUND_TEXT_LENGTH - 3).join("")}...`;
|
|
11
|
+
}
|
|
12
|
+
function readLatestFeishuDestination(tx) {
|
|
13
|
+
return (tx
|
|
14
|
+
.prepare(`
|
|
15
|
+
SELECT platform, installation_ref, binding_ref
|
|
16
|
+
FROM external_source_mappings
|
|
17
|
+
WHERE platform = 'feishu'
|
|
18
|
+
ORDER BY accepted_at DESC, message_id DESC
|
|
19
|
+
LIMIT 1
|
|
20
|
+
`)
|
|
21
|
+
.get() ?? null);
|
|
22
|
+
}
|
|
23
|
+
function readJsonObject(value) {
|
|
24
|
+
if (value === null) {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(value);
|
|
29
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
30
|
+
? parsed
|
|
31
|
+
: {};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function readNestedObject(value, key) {
|
|
38
|
+
const nested = value[key];
|
|
39
|
+
return typeof nested === "object" && nested !== null && !Array.isArray(nested)
|
|
40
|
+
? nested
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
export function initializePlatformOutboundEventCapture(db, now = () => new Date()) {
|
|
44
|
+
const timestamp = now().toISOString();
|
|
45
|
+
db.prepare(`
|
|
46
|
+
INSERT INTO platform_outbound_event_capture_state (id, capture_after, updated_at)
|
|
47
|
+
VALUES (1, ?, ?)
|
|
48
|
+
ON CONFLICT(id) DO NOTHING
|
|
49
|
+
`).run(timestamp, timestamp);
|
|
50
|
+
return (db
|
|
51
|
+
.prepare("SELECT capture_after FROM platform_outbound_event_capture_state WHERE id = 1")
|
|
52
|
+
.get()?.capture_after ?? timestamp);
|
|
53
|
+
}
|
|
54
|
+
export function createPlatformStatusOutboundIntent(tx, input) {
|
|
55
|
+
const existing = tx
|
|
56
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE event_key = ?")
|
|
57
|
+
.get(input.eventKey);
|
|
58
|
+
if (existing !== undefined) {
|
|
59
|
+
return { created: false, intent: existing };
|
|
60
|
+
}
|
|
61
|
+
const destination = readLatestFeishuDestination(tx);
|
|
62
|
+
if (destination === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const contentText = input.contentText?.trim();
|
|
66
|
+
if (input.canonicalMessageId === undefined &&
|
|
67
|
+
(contentText === undefined || contentText.length === 0) &&
|
|
68
|
+
input.content === undefined) {
|
|
69
|
+
throw new TypeError("Platform status outbound content is missing");
|
|
70
|
+
}
|
|
71
|
+
if (contentText !== undefined && [...contentText].length > 4_000) {
|
|
72
|
+
throw new TypeError("Platform status outbound content is too long");
|
|
73
|
+
}
|
|
74
|
+
if (input.content !== undefined && !Value.Check(PlatformOutboundContentSchema, input.content)) {
|
|
75
|
+
throw new TypeError("Platform status outbound structured content is invalid");
|
|
76
|
+
}
|
|
77
|
+
const contentJson = input.content === undefined ? null : JSON.stringify(input.content);
|
|
78
|
+
const timestamp = (input.now ?? (() => new Date()))().toISOString();
|
|
79
|
+
const intentRef = createIdempotencyKey();
|
|
80
|
+
tx.prepare(`
|
|
81
|
+
INSERT INTO platform_outbound_intents (
|
|
82
|
+
intent_ref, platform, installation_ref, binding_ref,
|
|
83
|
+
event_kind, event_key, subject_kind, subject_ref, target_kind,
|
|
84
|
+
canonical_message_id, content_text, content_json, disposition, attempt_count,
|
|
85
|
+
next_attempt_at, created_at, updated_at
|
|
86
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
87
|
+
`).run(intentRef, destination.platform, destination.installation_ref, destination.binding_ref, input.eventKind, input.eventKey, input.subjectKind, input.subjectRef, input.targetKind, input.canonicalMessageId ?? null, contentText ?? null, contentJson, "pending", 0, timestamp, timestamp, timestamp);
|
|
88
|
+
return {
|
|
89
|
+
created: true,
|
|
90
|
+
intent: tx
|
|
91
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE intent_ref = ?")
|
|
92
|
+
.get(intentRef),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function materializePlatformStatusOutboundIntents(db, now = () => new Date()) {
|
|
96
|
+
return db.transaction(() => {
|
|
97
|
+
const captureAfter = initializePlatformOutboundEventCapture(db, now);
|
|
98
|
+
const candidates = db
|
|
99
|
+
.prepare(`
|
|
100
|
+
SELECT messages.id, messages.message_kind, messages.refs_json
|
|
101
|
+
FROM messages
|
|
102
|
+
LEFT JOIN platform_outbound_intents
|
|
103
|
+
ON platform_outbound_intents.canonical_message_id = messages.id
|
|
104
|
+
LEFT JOIN clarification_rounds
|
|
105
|
+
ON clarification_rounds.card_message_id = messages.id
|
|
106
|
+
LEFT JOIN clarification_threads
|
|
107
|
+
ON clarification_threads.id = clarification_rounds.clarification_thread_id
|
|
108
|
+
WHERE messages.created_at >= ?
|
|
109
|
+
AND platform_outbound_intents.intent_ref IS NULL
|
|
110
|
+
AND (
|
|
111
|
+
(
|
|
112
|
+
messages.message_kind = 'clarification_card'
|
|
113
|
+
AND clarification_rounds.status = 'writable'
|
|
114
|
+
AND clarification_threads.status = 'active'
|
|
115
|
+
AND clarification_threads.current_round_id = clarification_rounds.id
|
|
116
|
+
)
|
|
117
|
+
OR json_type(messages.refs_json, '$.task_result') = 'object'
|
|
118
|
+
)
|
|
119
|
+
ORDER BY messages.created_at, messages.id
|
|
120
|
+
`)
|
|
121
|
+
.all(captureAfter);
|
|
122
|
+
const created = [];
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
const refs = readJsonObject(candidate.refs_json);
|
|
125
|
+
if (candidate.message_kind === "clarification_card") {
|
|
126
|
+
const roundRef = refs.clarification_round_ref;
|
|
127
|
+
if (typeof roundRef !== "string" || roundRef.length === 0) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const result = createPlatformStatusOutboundIntent(db, {
|
|
131
|
+
eventKind: "needs_input",
|
|
132
|
+
eventKey: `clarification_round:${roundRef}:opened`,
|
|
133
|
+
subjectKind: "clarification_round",
|
|
134
|
+
subjectRef: roundRef,
|
|
135
|
+
targetKind: "project",
|
|
136
|
+
canonicalMessageId: candidate.id,
|
|
137
|
+
now,
|
|
138
|
+
});
|
|
139
|
+
if (result?.created === true) {
|
|
140
|
+
created.push(result.intent);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const taskResult = readNestedObject(refs, "task_result");
|
|
145
|
+
const resultKey = taskResult?.result_key;
|
|
146
|
+
const status = taskResult?.status;
|
|
147
|
+
if (typeof resultKey !== "string" ||
|
|
148
|
+
resultKey.length === 0 ||
|
|
149
|
+
(status !== "completed" && status !== "failed")) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const result = createPlatformStatusOutboundIntent(db, {
|
|
153
|
+
eventKind: status === "completed" ? "task_completed" : "task_failed",
|
|
154
|
+
eventKey: `task_result:${resultKey}`,
|
|
155
|
+
subjectKind: "task_result",
|
|
156
|
+
subjectRef: resultKey,
|
|
157
|
+
targetKind: "work",
|
|
158
|
+
canonicalMessageId: candidate.id,
|
|
159
|
+
now,
|
|
160
|
+
});
|
|
161
|
+
if (result?.created === true) {
|
|
162
|
+
created.push(result.intent);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return created;
|
|
166
|
+
})();
|
|
167
|
+
}
|
|
2
168
|
export function createPlatformOutboundIntent(tx, input) {
|
|
3
169
|
const existing = tx
|
|
4
170
|
.prepare("SELECT * FROM platform_outbound_intents WHERE canonical_message_id = ?")
|
|
@@ -21,10 +187,11 @@ export function createPlatformOutboundIntent(tx, input) {
|
|
|
21
187
|
tx.prepare(`
|
|
22
188
|
INSERT INTO platform_outbound_intents (
|
|
23
189
|
intent_ref, platform, installation_ref, binding_ref,
|
|
190
|
+
event_kind, event_key, subject_kind, subject_ref, target_kind,
|
|
24
191
|
trigger_message_id, canonical_message_id, disposition, attempt_count,
|
|
25
192
|
next_attempt_at, created_at, updated_at
|
|
26
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
27
|
-
`).run(intentRef, destination.platform, destination.installation_ref, destination.binding_ref, input.triggerMessageId, input.canonicalMessageId, "pending", 0, timestamp, timestamp, timestamp);
|
|
193
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
194
|
+
`).run(intentRef, destination.platform, destination.installation_ref, destination.binding_ref, "chat_response", `chat_response:${input.canonicalMessageId}`, "message", input.canonicalMessageId, "project", input.triggerMessageId, input.canonicalMessageId, "pending", 0, timestamp, timestamp, timestamp);
|
|
28
195
|
return {
|
|
29
196
|
created: true,
|
|
30
197
|
intent: tx
|
|
@@ -48,9 +215,10 @@ export function claimDuePlatformOutboundIntent(db, now = () => new Date()) {
|
|
|
48
215
|
const timestamp = now().toISOString();
|
|
49
216
|
const row = db
|
|
50
217
|
.prepare(`
|
|
51
|
-
SELECT platform_outbound_intents.*,
|
|
218
|
+
SELECT platform_outbound_intents.*,
|
|
219
|
+
COALESCE(platform_outbound_intents.content_text, messages.body) AS body
|
|
52
220
|
FROM platform_outbound_intents
|
|
53
|
-
JOIN messages ON messages.id = platform_outbound_intents.canonical_message_id
|
|
221
|
+
LEFT JOIN messages ON messages.id = platform_outbound_intents.canonical_message_id
|
|
54
222
|
WHERE platform_outbound_intents.disposition IN ('pending', 'retry_wait')
|
|
55
223
|
AND (
|
|
56
224
|
platform_outbound_intents.next_attempt_at IS NULL
|
|
@@ -63,6 +231,23 @@ export function claimDuePlatformOutboundIntent(db, now = () => new Date()) {
|
|
|
63
231
|
if (row === undefined) {
|
|
64
232
|
return null;
|
|
65
233
|
}
|
|
234
|
+
let content = null;
|
|
235
|
+
if (row.content_json !== null) {
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(row.content_json);
|
|
238
|
+
content = Value.Check(PlatformOutboundContentSchema, parsed) ? parsed : null;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
content = null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (row.body !== null) {
|
|
245
|
+
const body = boundedPlatformOutboundText(row.body);
|
|
246
|
+
content = body.length === 0 ? null : { kind: "text", text: body };
|
|
247
|
+
}
|
|
248
|
+
if (content === null) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
66
251
|
db.prepare(`
|
|
67
252
|
UPDATE platform_outbound_intents
|
|
68
253
|
SET disposition = 'sending', attempt_count = attempt_count + 1,
|
|
@@ -72,7 +257,7 @@ export function claimDuePlatformOutboundIntent(db, now = () => new Date()) {
|
|
|
72
257
|
const intent = db
|
|
73
258
|
.prepare("SELECT * FROM platform_outbound_intents WHERE intent_ref = ?")
|
|
74
259
|
.get(row.intent_ref);
|
|
75
|
-
return { intent,
|
|
260
|
+
return { intent, content };
|
|
76
261
|
})();
|
|
77
262
|
}
|
|
78
263
|
export function markPlatformOutboundHandedOff(db, input) {
|
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { IdempotencyKey, MessageId } from "@tutti/shared/ids";
|
|
2
2
|
import type { MessageProjection } from "@tutti/shared/schemas/api";
|
|
3
3
|
import type { PlatformOutboundHandoffResponse } from "@tutti/shared/schemas/internal";
|
|
4
|
+
import type { PlatformOutboundContent } from "@tutti/shared/schemas/internal";
|
|
4
5
|
export type HostPlatformOutboundIntentDisposition = "pending" | "sending" | "retry_wait" | "handed_off" | "terminal";
|
|
6
|
+
export type HostPlatformOutboundEventKind = "chat_response" | "needs_input" | "task_completed" | "task_failed";
|
|
7
|
+
export type HostPlatformOutboundSubjectKind = "message" | "clarification_round" | "approval_request" | "task_result";
|
|
5
8
|
export type HostPlatformOutboundIntentRecord = {
|
|
6
9
|
intent_ref: IdempotencyKey;
|
|
7
10
|
platform: "feishu";
|
|
8
11
|
installation_ref: string;
|
|
9
12
|
binding_ref: string;
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
event_kind: HostPlatformOutboundEventKind;
|
|
14
|
+
event_key: string;
|
|
15
|
+
subject_kind: HostPlatformOutboundSubjectKind;
|
|
16
|
+
subject_ref: string;
|
|
17
|
+
target_kind: "project" | "work";
|
|
18
|
+
trigger_message_id: MessageId | null;
|
|
19
|
+
canonical_message_id: MessageId | null;
|
|
20
|
+
content_text: string | null;
|
|
21
|
+
content_json: string | null;
|
|
12
22
|
disposition: HostPlatformOutboundIntentDisposition;
|
|
13
23
|
attempt_count: number;
|
|
14
24
|
next_attempt_at: string | null;
|
|
@@ -21,7 +31,7 @@ export type HostPlatformOutboundIntentRecord = {
|
|
|
21
31
|
};
|
|
22
32
|
export type HostPlatformOutboundWorkItem = {
|
|
23
33
|
intent: HostPlatformOutboundIntentRecord;
|
|
24
|
-
|
|
34
|
+
content: PlatformOutboundContent;
|
|
25
35
|
};
|
|
26
36
|
export type HostPlatformOutboundTransport = {
|
|
27
37
|
deliver(item: HostPlatformOutboundWorkItem): Promise<PlatformOutboundHandoffResponse>;
|