@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.
Files changed (61) hide show
  1. package/README.md +2 -2
  2. package/dist/approvals/decision-command.d.ts +124 -0
  3. package/dist/approvals/decision-command.js +20 -0
  4. package/dist/approvals/index.d.ts +1 -0
  5. package/dist/approvals/index.js +1 -0
  6. package/dist/approvals/manager.d.ts +3 -1
  7. package/dist/approvals/manager.js +9 -0
  8. package/dist/collaboration-ingestion/index.d.ts +3 -0
  9. package/dist/collaboration-ingestion/index.js +3 -0
  10. package/dist/collaboration-ingestion/managed-reference-snapshots.d.ts +27 -0
  11. package/dist/collaboration-ingestion/managed-reference-snapshots.js +45 -0
  12. package/dist/collaboration-ingestion/managed-reference-sources.d.ts +20 -0
  13. package/dist/collaboration-ingestion/managed-reference-sources.js +250 -0
  14. package/dist/collaboration-ingestion/reference-attachments.d.ts +22 -0
  15. package/dist/collaboration-ingestion/reference-attachments.js +132 -0
  16. package/dist/collaboration-ingestion/reference-files.d.ts +3 -2
  17. package/dist/collaboration-ingestion/reference-files.js +1 -0
  18. package/dist/platform-outbound/coordinator.d.ts +5 -2
  19. package/dist/platform-outbound/coordinator.js +95 -2
  20. package/dist/platform-outbound/repository.d.ts +17 -0
  21. package/dist/platform-outbound/repository.js +190 -5
  22. package/dist/platform-outbound/types.d.ts +13 -3
  23. package/dist/platform-outbound/worker.js +30 -14
  24. package/dist/server-shell/cli/host-lifecycle.js +3 -0
  25. package/dist/server-shell/cli/host-server-runtime.d.ts +4 -1
  26. package/dist/server-shell/cli/host-server-runtime.js +128 -40
  27. package/dist/server-shell/cli/relay-registration.js +3 -0
  28. package/dist/server-shell/http/host-tunnel.d.ts +4 -1
  29. package/dist/server-shell/http/host-tunnel.js +174 -3
  30. package/dist/server-shell/http/routes/project-api/approval-routes.js +13 -20
  31. package/dist/server-shell/http/routes/project-api/staged-uploads.d.ts +6 -0
  32. package/dist/server-shell/http/routes/project-api/staged-uploads.js +9 -2
  33. package/dist/workspace-ops/index.d.ts +2 -2
  34. package/dist/workspace-ops/index.js +1 -1
  35. package/dist/workspace-ops/reference-files.d.ts +14 -1
  36. package/dist/workspace-ops/reference-files.js +167 -2
  37. package/dist/workspace-ops/types.d.ts +24 -0
  38. package/migrations/0018_platform_outbound_events.sql +111 -0
  39. package/migrations/0019_platform_outbound_card_content.sql +76 -0
  40. package/migrations/0020_managed_reference_sources.sql +34 -0
  41. package/migrations/README.md +4 -1
  42. package/node_modules/@tutti/relay-client/dist/host-control.d.ts +5 -2
  43. package/node_modules/@tutti/relay-client/dist/host-control.js +3 -0
  44. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +2 -2
  45. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  46. package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +34 -0
  47. package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +37 -0
  48. package/node_modules/@tutti/shared/dist/schemas/internal/feishu-user-credentials.d.ts +22 -0
  49. package/node_modules/@tutti/shared/dist/schemas/internal/feishu-user-credentials.js +37 -0
  50. package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +1 -0
  51. package/node_modules/@tutti/shared/dist/schemas/internal/index.js +1 -0
  52. package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1005 -49
  53. package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +434 -14
  54. package/package.json +1 -1
  55. package/prompts/skills/read-references/README.md +1 -1
  56. package/prompts/skills/read-references/SKILL.md +4 -2
  57. package/web/assets/{homepage-motion-scene-BnRTgItL.js → homepage-motion-scene-tfFEdV9Y.js} +1 -1
  58. package/web/assets/index-B7ZGIvOv.js +69 -0
  59. package/web/assets/{index-CTMOjCgU.css → index-C2Mmj25S.css} +1 -1
  60. package/web/index.html +2 -2
  61. 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 { createPlatformOutboundIntent } from "./repository.js";
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.*, messages.body
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, body: row.body };
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
- trigger_message_id: MessageId;
11
- canonical_message_id: MessageId;
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
- body: string;
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
- canonical_message_ref: item.intent.canonical_message_id,
71
- trigger_message_ref: item.intent.trigger_message_id,
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,