@xfey/tutti 0.1.105 → 0.1.106
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 +2 -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/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/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/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/index.d.ts +2 -2
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +34 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +37 -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 +1 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.js +1 -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-tfFEdV9Y.js} +1 -1
- package/web/assets/index-B7ZGIvOv.js +69 -0
- package/web/assets/{index-CTMOjCgU.css → index-C2Mmj25S.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,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>;
|
|
@@ -1,14 +1,7 @@
|
|
|
1
|
-
import { claimDuePlatformOutboundIntent, markPlatformOutboundHandedOff, markPlatformOutboundRetry, markPlatformOutboundTerminal, readPlatformOutboundIntent, recoverInterruptedPlatformOutboundIntents, } from "./repository.js";
|
|
1
|
+
import { claimDuePlatformOutboundIntent, initializePlatformOutboundEventCapture, markPlatformOutboundHandedOff, markPlatformOutboundRetry, markPlatformOutboundTerminal, readPlatformOutboundIntent, recoverInterruptedPlatformOutboundIntents, materializePlatformStatusOutboundIntents, } from "./repository.js";
|
|
2
2
|
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
3
3
|
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
4
4
|
const MAX_BACKOFF_MS = 5 * 60 * 1_000;
|
|
5
|
-
function boundedPlatformText(value) {
|
|
6
|
-
const trimmed = value.trim();
|
|
7
|
-
const characters = [...trimmed];
|
|
8
|
-
return characters.length <= 4_000
|
|
9
|
-
? trimmed
|
|
10
|
-
: `${characters.slice(0, 3_997).join("")}...`;
|
|
11
|
-
}
|
|
12
5
|
export class HostPlatformOutboundWorker {
|
|
13
6
|
store;
|
|
14
7
|
transport;
|
|
@@ -33,6 +26,7 @@ export class HostPlatformOutboundWorker {
|
|
|
33
26
|
return;
|
|
34
27
|
}
|
|
35
28
|
this.stopped = false;
|
|
29
|
+
initializePlatformOutboundEventCapture(this.store.db, this.now);
|
|
36
30
|
const recovered = recoverInterruptedPlatformOutboundIntents(this.store.db, this.now);
|
|
37
31
|
if (recovered > 0) {
|
|
38
32
|
this.logger.warn({ stage: "platform_outbound_recovery", disposition: "retry_wait", count: recovered }, "Interrupted platform outbound intents recovered");
|
|
@@ -61,24 +55,46 @@ export class HostPlatformOutboundWorker {
|
|
|
61
55
|
await this.current;
|
|
62
56
|
}
|
|
63
57
|
async runOnce() {
|
|
58
|
+
const materialized = materializePlatformStatusOutboundIntents(this.store.db, this.now);
|
|
59
|
+
for (const intent of materialized) {
|
|
60
|
+
this.logger.info({
|
|
61
|
+
stage: "platform_outbound_materialize",
|
|
62
|
+
disposition: "created",
|
|
63
|
+
outbound_intent_ref: intent.intent_ref,
|
|
64
|
+
event_kind: intent.event_kind,
|
|
65
|
+
event_key: intent.event_key,
|
|
66
|
+
subject_kind: intent.subject_kind,
|
|
67
|
+
subject_ref: intent.subject_ref,
|
|
68
|
+
target_kind: intent.target_kind,
|
|
69
|
+
...(intent.canonical_message_id === null
|
|
70
|
+
? {}
|
|
71
|
+
: { canonical_message_ref: intent.canonical_message_id }),
|
|
72
|
+
}, "Platform outbound status intent materialized");
|
|
73
|
+
}
|
|
64
74
|
const item = claimDuePlatformOutboundIntent(this.store.db, this.now);
|
|
65
75
|
if (item === null) {
|
|
66
76
|
return { kind: "idle" };
|
|
67
77
|
}
|
|
68
78
|
const fields = {
|
|
69
79
|
outbound_intent_ref: item.intent.intent_ref,
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
event_kind: item.intent.event_kind,
|
|
81
|
+
event_key: item.intent.event_key,
|
|
82
|
+
subject_kind: item.intent.subject_kind,
|
|
83
|
+
subject_ref: item.intent.subject_ref,
|
|
84
|
+
target_kind: item.intent.target_kind,
|
|
85
|
+
...(item.intent.canonical_message_id === null
|
|
86
|
+
? {}
|
|
87
|
+
: { canonical_message_ref: item.intent.canonical_message_id }),
|
|
88
|
+
...(item.intent.trigger_message_id === null
|
|
89
|
+
? {}
|
|
90
|
+
: { trigger_message_ref: item.intent.trigger_message_id }),
|
|
72
91
|
installation_ref: item.intent.installation_ref,
|
|
73
92
|
binding_ref: item.intent.binding_ref,
|
|
74
93
|
attempt: item.intent.attempt_count,
|
|
75
94
|
};
|
|
76
95
|
this.logger.info({ ...fields, stage: "platform_outbound_attempt", disposition: "sending" }, "Platform outbound handoff attempted");
|
|
77
96
|
try {
|
|
78
|
-
const response = await this.transport.deliver(
|
|
79
|
-
...item,
|
|
80
|
-
body: boundedPlatformText(item.body),
|
|
81
|
-
});
|
|
97
|
+
const response = await this.transport.deliver(item);
|
|
82
98
|
if (response.disposition.kind === "accepted" || response.disposition.kind === "duplicate") {
|
|
83
99
|
markPlatformOutboundHandedOff(this.store.db, {
|
|
84
100
|
intentRef: item.intent.intent_ref,
|
|
@@ -311,6 +311,9 @@ export async function startLaunchProject(options) {
|
|
|
311
311
|
projectSurface: preparation.machine_local.binding.surface_profile.kind,
|
|
312
312
|
trustedMetadataStore: host.trusted_relay_metadata_store,
|
|
313
313
|
integrationDelivery: host.integration_delivery,
|
|
314
|
+
integrationReferenceAttachment: host.integration_reference_attachment,
|
|
315
|
+
integrationManagedReference: host.integration_managed_reference,
|
|
316
|
+
integrationApprovalDecision: host.integration_approval_decision,
|
|
314
317
|
}),
|
|
315
318
|
handleStreamRequest: createHostProjectApiTunnelStreamRequestHandler({
|
|
316
319
|
baseUrl: host.runtime_endpoint.base_url,
|