@xfey/tutti 0.1.103 → 0.1.105
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 +9 -4
- package/dist/chat-assistant/index.d.ts +6 -1
- package/dist/chat-assistant/index.js +22 -12
- package/dist/collaboration-ingestion/external-source.d.ts +15 -0
- package/dist/collaboration-ingestion/external-source.js +120 -0
- package/dist/collaboration-ingestion/index.d.ts +5 -0
- package/dist/collaboration-ingestion/index.js +4 -0
- package/dist/collaboration-ingestion/invalidations.d.ts +4 -0
- package/dist/collaboration-ingestion/invalidations.js +15 -0
- package/dist/collaboration-ingestion/main-chat.d.ts +33 -0
- package/dist/collaboration-ingestion/main-chat.js +67 -0
- package/dist/collaboration-ingestion/reference-files.d.ts +29 -0
- package/dist/collaboration-ingestion/reference-files.js +158 -0
- package/dist/collaboration-ingestion/types.d.ts +19 -0
- package/dist/collaboration-ingestion/types.js +2 -0
- package/dist/platform-outbound/coordinator.d.ts +17 -0
- package/dist/platform-outbound/coordinator.js +48 -0
- package/dist/platform-outbound/index.d.ts +5 -0
- package/dist/platform-outbound/index.js +5 -0
- package/dist/platform-outbound/repository.d.ts +30 -0
- package/dist/platform-outbound/repository.js +108 -0
- package/dist/platform-outbound/types.d.ts +38 -0
- package/dist/platform-outbound/types.js +2 -0
- package/dist/platform-outbound/worker.d.ts +36 -0
- package/dist/platform-outbound/worker.js +164 -0
- package/dist/server-shell/cli/cli.js +9 -0
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/host-lifecycle.d.ts +2 -0
- package/dist/server-shell/cli/host-lifecycle.js +34 -1
- package/dist/server-shell/cli/host-relay-connection-manager.js +22 -11
- package/dist/server-shell/cli/host-relay-status.js +1 -0
- package/dist/server-shell/cli/host-runtime-endpoint.js +17 -1
- package/dist/server-shell/cli/host-server-runtime.d.ts +2 -0
- package/dist/server-shell/cli/host-server-runtime.js +88 -14
- package/dist/server-shell/cli/launch-command.d.ts +7 -0
- package/dist/server-shell/cli/launch-command.js +39 -1
- package/dist/server-shell/cli/launch.d.ts +2 -0
- package/dist/server-shell/cli/launch.js +3 -0
- package/dist/server-shell/cli/machine-local.d.ts +20 -0
- package/dist/server-shell/cli/machine-local.js +61 -1
- package/dist/server-shell/cli/managed-host.d.ts +16 -1
- package/dist/server-shell/cli/managed-host.js +191 -23
- package/dist/server-shell/cli/relay-registration.d.ts +4 -1
- package/dist/server-shell/cli/relay-registration.js +36 -7
- package/dist/server-shell/cli/runtime-commands.d.ts +5 -1
- package/dist/server-shell/cli/runtime-commands.js +9 -3
- package/dist/server-shell/http/host-tunnel.d.ts +5 -0
- package/dist/server-shell/http/host-tunnel.js +109 -0
- package/dist/server-shell/http/routes/local-control.d.ts +2 -0
- package/dist/server-shell/http/routes/local-control.js +2 -0
- package/dist/server-shell/http/routes/project-api/invalidations.d.ts +0 -1
- package/dist/server-shell/http/routes/project-api/invalidations.js +0 -3
- package/dist/server-shell/http/routes/project-api/messages-routes.js +32 -63
- package/dist/server-shell/http/routes/project-api/openapi-workspace-routes.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/openapi.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/reference-files-routes.js +25 -94
- package/dist/server-shell/http/routes/project-api/reference-files.d.ts +1 -11
- package/dist/server-shell/http/routes/project-api/reference-files.js +2 -87
- package/dist/server-shell/http/routes/project-api/schemas.d.ts +8 -4
- package/dist/server-shell/http/routes/project-api/schemas.js +8 -3
- package/dist/server-shell/http/routes/project-api/session-requirements.js +34 -2
- package/dist/server-shell/http/routes/project-api/types.d.ts +3 -1
- package/dist/server-shell/http/routes/project-api/workspace-projections.js +8 -2
- package/dist/server-shell/local-console/package-update-hosts.js +4 -1
- package/dist/server-shell/local-console/project-service.d.ts +13 -2
- package/dist/server-shell/local-console/project-service.js +73 -8
- package/dist/server-shell/local-console/server.js +10 -1
- package/dist/server-shell/local-console/session.d.ts +4 -0
- package/dist/server-shell/local-console/session.js +14 -0
- package/dist/server-shell/session/relay-session-context.d.ts +12 -1
- package/dist/server-shell/session/relay-session-context.js +19 -1
- package/migrations/0016_external_source_mappings.sql +141 -0
- package/migrations/0017_platform_outbound_intents.sql +31 -0
- package/migrations/README.md +3 -1
- package/node_modules/@tutti/relay-client/dist/host-control.d.ts +26 -1
- package/node_modules/@tutti/relay-client/dist/host-control.js +62 -0
- package/node_modules/@tutti/relay-client/dist/tunnel-frames.js +32 -6
- package/node_modules/@tutti/relay-client/dist/tunnel-types.d.ts +5 -1
- package/node_modules/@tutti/shared/dist/domain/index.d.ts +9 -0
- package/node_modules/@tutti/shared/dist/domain/index.js +14 -0
- package/node_modules/@tutti/shared/dist/ids/index.d.ts +6 -0
- package/node_modules/@tutti/shared/dist/ids/index.js +4 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +4 -0
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +2 -0
- package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.d.ts +13 -0
- package/node_modules/@tutti/shared/dist/schemas/api/local-console-account-handoffs.js +16 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.d.ts +43 -0
- package/node_modules/@tutti/shared/dist/schemas/api/platform-sessions.js +44 -0
- package/node_modules/@tutti/shared/dist/schemas/domain/index.d.ts +7 -0
- package/node_modules/@tutti/shared/dist/schemas/domain/index.js +10 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.d.ts +29 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/index.js +24 -1
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.d.ts +1346 -0
- package/node_modules/@tutti/shared/dist/schemas/internal/platform-integration.js +516 -0
- package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.d.ts +10 -0
- package/node_modules/@tutti/shared/dist/utils/feishu-diagnostics/index.js +19 -0
- package/node_modules/@tutti/shared/dist/utils/index.d.ts +1 -0
- package/node_modules/@tutti/shared/dist/utils/index.js +1 -0
- package/node_modules/@tutti/shared/dist/utils/redaction/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/utils/redaction/index.js +18 -0
- package/package.json +1 -1
- package/web/assets/{homepage-motion-scene-CPs4EYQq.js → homepage-motion-scene-BnRTgItL.js} +1 -1
- package/web/assets/{index-CxCTqGFO.css → index-CTMOjCgU.css} +1 -1
- package/web/assets/index-CmzEr4-V.js +69 -0
- package/web/index.html +2 -2
- package/web/assets/index-D3lwP_UR.js +0 -69
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createPlatformOutboundIntent } from "./repository.js";
|
|
2
|
+
export class HostPlatformOutboundCoordinator {
|
|
3
|
+
worker;
|
|
4
|
+
logger;
|
|
5
|
+
now;
|
|
6
|
+
constructor(worker, logger, now = () => new Date()) {
|
|
7
|
+
this.worker = worker;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.now = now;
|
|
10
|
+
}
|
|
11
|
+
captureAgentResponse(tx, input) {
|
|
12
|
+
const result = createPlatformOutboundIntent(tx, {
|
|
13
|
+
triggerMessageId: input.triggerMessage.id,
|
|
14
|
+
canonicalMessageId: input.responseMessage.id,
|
|
15
|
+
now: this.now,
|
|
16
|
+
});
|
|
17
|
+
if (result === null) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return () => {
|
|
21
|
+
try {
|
|
22
|
+
this.logger.info({
|
|
23
|
+
stage: "platform_outbound_capture",
|
|
24
|
+
disposition: result.created ? "created" : "duplicate",
|
|
25
|
+
outbound_intent_ref: result.intent.intent_ref,
|
|
26
|
+
canonical_message_ref: result.intent.canonical_message_id,
|
|
27
|
+
trigger_message_ref: result.intent.trigger_message_id,
|
|
28
|
+
installation_ref: result.intent.installation_ref,
|
|
29
|
+
binding_ref: result.intent.binding_ref,
|
|
30
|
+
}, result.created
|
|
31
|
+
? "Platform outbound intent created"
|
|
32
|
+
: "Platform outbound intent already exists");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// The committed canonical response and durable intent remain authoritative even when the
|
|
36
|
+
// machine-local log cannot be appended. The polling worker still guarantees handoff.
|
|
37
|
+
}
|
|
38
|
+
this.worker.wake();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
start() {
|
|
42
|
+
this.worker.start();
|
|
43
|
+
}
|
|
44
|
+
stop() {
|
|
45
|
+
return this.worker.stop();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=coordinator.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SqliteDatabase } from "../store/index.js";
|
|
2
|
+
import type { HostPlatformOutboundIntentRecord, HostPlatformOutboundWorkItem } from "./types.js";
|
|
3
|
+
export declare function createPlatformOutboundIntent(tx: SqliteDatabase, input: {
|
|
4
|
+
triggerMessageId: string;
|
|
5
|
+
canonicalMessageId: string;
|
|
6
|
+
now?: () => Date;
|
|
7
|
+
}): {
|
|
8
|
+
created: boolean;
|
|
9
|
+
intent: HostPlatformOutboundIntentRecord;
|
|
10
|
+
} | null;
|
|
11
|
+
export declare function recoverInterruptedPlatformOutboundIntents(db: SqliteDatabase, now?: () => Date): number;
|
|
12
|
+
export declare function claimDuePlatformOutboundIntent(db: SqliteDatabase, now?: () => Date): HostPlatformOutboundWorkItem | null;
|
|
13
|
+
export declare function markPlatformOutboundHandedOff(db: SqliteDatabase, input: {
|
|
14
|
+
intentRef: string;
|
|
15
|
+
deliveryRef: string;
|
|
16
|
+
now?: () => Date;
|
|
17
|
+
}): void;
|
|
18
|
+
export declare function markPlatformOutboundRetry(db: SqliteDatabase, input: {
|
|
19
|
+
intentRef: string;
|
|
20
|
+
errorCode: string;
|
|
21
|
+
nextAttemptAt: string;
|
|
22
|
+
now?: () => Date;
|
|
23
|
+
}): void;
|
|
24
|
+
export declare function markPlatformOutboundTerminal(db: SqliteDatabase, input: {
|
|
25
|
+
intentRef: string;
|
|
26
|
+
errorCode: string;
|
|
27
|
+
now?: () => Date;
|
|
28
|
+
}): void;
|
|
29
|
+
export declare function readPlatformOutboundIntent(db: SqliteDatabase, intentRef: string): HostPlatformOutboundIntentRecord | null;
|
|
30
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createIdempotencyKey } from "@tutti/shared/ids";
|
|
2
|
+
export function createPlatformOutboundIntent(tx, input) {
|
|
3
|
+
const existing = tx
|
|
4
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE canonical_message_id = ?")
|
|
5
|
+
.get(input.canonicalMessageId);
|
|
6
|
+
if (existing !== undefined) {
|
|
7
|
+
return { created: false, intent: existing };
|
|
8
|
+
}
|
|
9
|
+
const destination = tx
|
|
10
|
+
.prepare(`
|
|
11
|
+
SELECT platform, installation_ref, binding_ref
|
|
12
|
+
FROM external_source_mappings
|
|
13
|
+
WHERE message_id = ? AND platform = 'feishu'
|
|
14
|
+
`)
|
|
15
|
+
.get(input.triggerMessageId);
|
|
16
|
+
if (destination === undefined) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const timestamp = (input.now ?? (() => new Date()))().toISOString();
|
|
20
|
+
const intentRef = createIdempotencyKey();
|
|
21
|
+
tx.prepare(`
|
|
22
|
+
INSERT INTO platform_outbound_intents (
|
|
23
|
+
intent_ref, platform, installation_ref, binding_ref,
|
|
24
|
+
trigger_message_id, canonical_message_id, disposition, attempt_count,
|
|
25
|
+
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);
|
|
28
|
+
return {
|
|
29
|
+
created: true,
|
|
30
|
+
intent: tx
|
|
31
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE intent_ref = ?")
|
|
32
|
+
.get(intentRef),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function recoverInterruptedPlatformOutboundIntents(db, now = () => new Date()) {
|
|
36
|
+
const timestamp = now().toISOString();
|
|
37
|
+
return db
|
|
38
|
+
.prepare(`
|
|
39
|
+
UPDATE platform_outbound_intents
|
|
40
|
+
SET disposition = 'retry_wait', error_code = 'worker_interrupted',
|
|
41
|
+
next_attempt_at = ?, updated_at = ?
|
|
42
|
+
WHERE disposition = 'sending'
|
|
43
|
+
`)
|
|
44
|
+
.run(timestamp, timestamp).changes;
|
|
45
|
+
}
|
|
46
|
+
export function claimDuePlatformOutboundIntent(db, now = () => new Date()) {
|
|
47
|
+
return db.transaction(() => {
|
|
48
|
+
const timestamp = now().toISOString();
|
|
49
|
+
const row = db
|
|
50
|
+
.prepare(`
|
|
51
|
+
SELECT platform_outbound_intents.*, messages.body
|
|
52
|
+
FROM platform_outbound_intents
|
|
53
|
+
JOIN messages ON messages.id = platform_outbound_intents.canonical_message_id
|
|
54
|
+
WHERE platform_outbound_intents.disposition IN ('pending', 'retry_wait')
|
|
55
|
+
AND (
|
|
56
|
+
platform_outbound_intents.next_attempt_at IS NULL
|
|
57
|
+
OR platform_outbound_intents.next_attempt_at <= ?
|
|
58
|
+
)
|
|
59
|
+
ORDER BY platform_outbound_intents.created_at, platform_outbound_intents.intent_ref
|
|
60
|
+
LIMIT 1
|
|
61
|
+
`)
|
|
62
|
+
.get(timestamp);
|
|
63
|
+
if (row === undefined) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
db.prepare(`
|
|
67
|
+
UPDATE platform_outbound_intents
|
|
68
|
+
SET disposition = 'sending', attempt_count = attempt_count + 1,
|
|
69
|
+
last_attempt_at = ?, next_attempt_at = NULL, updated_at = ?
|
|
70
|
+
WHERE intent_ref = ?
|
|
71
|
+
`).run(timestamp, timestamp, row.intent_ref);
|
|
72
|
+
const intent = db
|
|
73
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE intent_ref = ?")
|
|
74
|
+
.get(row.intent_ref);
|
|
75
|
+
return { intent, body: row.body };
|
|
76
|
+
})();
|
|
77
|
+
}
|
|
78
|
+
export function markPlatformOutboundHandedOff(db, input) {
|
|
79
|
+
const timestamp = (input.now ?? (() => new Date()))().toISOString();
|
|
80
|
+
db.prepare(`
|
|
81
|
+
UPDATE platform_outbound_intents
|
|
82
|
+
SET disposition = 'handed_off', delivery_ref = ?, error_code = NULL,
|
|
83
|
+
handed_off_at = ?, updated_at = ?
|
|
84
|
+
WHERE intent_ref = ?
|
|
85
|
+
`).run(input.deliveryRef, timestamp, timestamp, input.intentRef);
|
|
86
|
+
}
|
|
87
|
+
export function markPlatformOutboundRetry(db, input) {
|
|
88
|
+
const timestamp = (input.now ?? (() => new Date()))().toISOString();
|
|
89
|
+
db.prepare(`
|
|
90
|
+
UPDATE platform_outbound_intents
|
|
91
|
+
SET disposition = 'retry_wait', error_code = ?, next_attempt_at = ?, updated_at = ?
|
|
92
|
+
WHERE intent_ref = ?
|
|
93
|
+
`).run(input.errorCode, input.nextAttemptAt, timestamp, input.intentRef);
|
|
94
|
+
}
|
|
95
|
+
export function markPlatformOutboundTerminal(db, input) {
|
|
96
|
+
const timestamp = (input.now ?? (() => new Date()))().toISOString();
|
|
97
|
+
db.prepare(`
|
|
98
|
+
UPDATE platform_outbound_intents
|
|
99
|
+
SET disposition = 'terminal', error_code = ?, next_attempt_at = NULL, updated_at = ?
|
|
100
|
+
WHERE intent_ref = ?
|
|
101
|
+
`).run(input.errorCode, timestamp, input.intentRef);
|
|
102
|
+
}
|
|
103
|
+
export function readPlatformOutboundIntent(db, intentRef) {
|
|
104
|
+
return (db
|
|
105
|
+
.prepare("SELECT * FROM platform_outbound_intents WHERE intent_ref = ?")
|
|
106
|
+
.get(intentRef) ?? null);
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=repository.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IdempotencyKey, MessageId } from "@tutti/shared/ids";
|
|
2
|
+
import type { MessageProjection } from "@tutti/shared/schemas/api";
|
|
3
|
+
import type { PlatformOutboundHandoffResponse } from "@tutti/shared/schemas/internal";
|
|
4
|
+
export type HostPlatformOutboundIntentDisposition = "pending" | "sending" | "retry_wait" | "handed_off" | "terminal";
|
|
5
|
+
export type HostPlatformOutboundIntentRecord = {
|
|
6
|
+
intent_ref: IdempotencyKey;
|
|
7
|
+
platform: "feishu";
|
|
8
|
+
installation_ref: string;
|
|
9
|
+
binding_ref: string;
|
|
10
|
+
trigger_message_id: MessageId;
|
|
11
|
+
canonical_message_id: MessageId;
|
|
12
|
+
disposition: HostPlatformOutboundIntentDisposition;
|
|
13
|
+
attempt_count: number;
|
|
14
|
+
next_attempt_at: string | null;
|
|
15
|
+
last_attempt_at: string | null;
|
|
16
|
+
delivery_ref: string | null;
|
|
17
|
+
error_code: string | null;
|
|
18
|
+
created_at: string;
|
|
19
|
+
updated_at: string;
|
|
20
|
+
handed_off_at: string | null;
|
|
21
|
+
};
|
|
22
|
+
export type HostPlatformOutboundWorkItem = {
|
|
23
|
+
intent: HostPlatformOutboundIntentRecord;
|
|
24
|
+
body: string;
|
|
25
|
+
};
|
|
26
|
+
export type HostPlatformOutboundTransport = {
|
|
27
|
+
deliver(item: HostPlatformOutboundWorkItem): Promise<PlatformOutboundHandoffResponse>;
|
|
28
|
+
};
|
|
29
|
+
export type HostPlatformOutboundLogger = {
|
|
30
|
+
info(fields: Record<string, unknown>, message: string): void;
|
|
31
|
+
warn(fields: Record<string, unknown>, message: string): void;
|
|
32
|
+
error(fields: Record<string, unknown>, message: string): void;
|
|
33
|
+
};
|
|
34
|
+
export type HostPlatformOutboundAgentResponseInput = {
|
|
35
|
+
triggerMessage: MessageProjection;
|
|
36
|
+
responseMessage: MessageProjection;
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { HostProjectStore } from "../store/index.js";
|
|
2
|
+
import type { HostPlatformOutboundIntentRecord, HostPlatformOutboundLogger, HostPlatformOutboundTransport } from "./types.js";
|
|
3
|
+
export type HostPlatformOutboundRunResult = {
|
|
4
|
+
kind: "idle";
|
|
5
|
+
} | {
|
|
6
|
+
kind: "handed_off";
|
|
7
|
+
intent: HostPlatformOutboundIntentRecord;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "retry_wait" | "terminal";
|
|
10
|
+
intent: HostPlatformOutboundIntentRecord;
|
|
11
|
+
error_code: string;
|
|
12
|
+
};
|
|
13
|
+
export declare class HostPlatformOutboundWorker {
|
|
14
|
+
private readonly store;
|
|
15
|
+
private readonly transport;
|
|
16
|
+
private readonly logger;
|
|
17
|
+
private readonly now;
|
|
18
|
+
private readonly pollIntervalMs;
|
|
19
|
+
private readonly maxAttempts;
|
|
20
|
+
private timer;
|
|
21
|
+
private current;
|
|
22
|
+
private stopped;
|
|
23
|
+
private wakeRequested;
|
|
24
|
+
constructor(store: HostProjectStore, transport: HostPlatformOutboundTransport, logger: HostPlatformOutboundLogger, options?: {
|
|
25
|
+
now?: () => Date;
|
|
26
|
+
pollIntervalMs?: number;
|
|
27
|
+
maxAttempts?: number;
|
|
28
|
+
});
|
|
29
|
+
start(): void;
|
|
30
|
+
wake(): void;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
runOnce(): Promise<HostPlatformOutboundRunResult>;
|
|
33
|
+
private markFailure;
|
|
34
|
+
private schedule;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=worker.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { claimDuePlatformOutboundIntent, markPlatformOutboundHandedOff, markPlatformOutboundRetry, markPlatformOutboundTerminal, readPlatformOutboundIntent, recoverInterruptedPlatformOutboundIntents, } from "./repository.js";
|
|
2
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
3
|
+
const DEFAULT_MAX_ATTEMPTS = 8;
|
|
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
|
+
export class HostPlatformOutboundWorker {
|
|
13
|
+
store;
|
|
14
|
+
transport;
|
|
15
|
+
logger;
|
|
16
|
+
now;
|
|
17
|
+
pollIntervalMs;
|
|
18
|
+
maxAttempts;
|
|
19
|
+
timer;
|
|
20
|
+
current;
|
|
21
|
+
stopped = true;
|
|
22
|
+
wakeRequested = false;
|
|
23
|
+
constructor(store, transport, logger, options = {}) {
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.transport = transport;
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
this.now = options.now ?? (() => new Date());
|
|
28
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
29
|
+
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
30
|
+
}
|
|
31
|
+
start() {
|
|
32
|
+
if (!this.stopped) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.stopped = false;
|
|
36
|
+
const recovered = recoverInterruptedPlatformOutboundIntents(this.store.db, this.now);
|
|
37
|
+
if (recovered > 0) {
|
|
38
|
+
this.logger.warn({ stage: "platform_outbound_recovery", disposition: "retry_wait", count: recovered }, "Interrupted platform outbound intents recovered");
|
|
39
|
+
}
|
|
40
|
+
this.schedule(0);
|
|
41
|
+
}
|
|
42
|
+
wake() {
|
|
43
|
+
if (this.stopped) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (this.current !== undefined) {
|
|
47
|
+
this.wakeRequested = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (this.timer !== undefined) {
|
|
51
|
+
clearTimeout(this.timer);
|
|
52
|
+
}
|
|
53
|
+
this.schedule(0);
|
|
54
|
+
}
|
|
55
|
+
async stop() {
|
|
56
|
+
this.stopped = true;
|
|
57
|
+
if (this.timer !== undefined) {
|
|
58
|
+
clearTimeout(this.timer);
|
|
59
|
+
this.timer = undefined;
|
|
60
|
+
}
|
|
61
|
+
await this.current;
|
|
62
|
+
}
|
|
63
|
+
async runOnce() {
|
|
64
|
+
const item = claimDuePlatformOutboundIntent(this.store.db, this.now);
|
|
65
|
+
if (item === null) {
|
|
66
|
+
return { kind: "idle" };
|
|
67
|
+
}
|
|
68
|
+
const fields = {
|
|
69
|
+
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,
|
|
72
|
+
installation_ref: item.intent.installation_ref,
|
|
73
|
+
binding_ref: item.intent.binding_ref,
|
|
74
|
+
attempt: item.intent.attempt_count,
|
|
75
|
+
};
|
|
76
|
+
this.logger.info({ ...fields, stage: "platform_outbound_attempt", disposition: "sending" }, "Platform outbound handoff attempted");
|
|
77
|
+
try {
|
|
78
|
+
const response = await this.transport.deliver({
|
|
79
|
+
...item,
|
|
80
|
+
body: boundedPlatformText(item.body),
|
|
81
|
+
});
|
|
82
|
+
if (response.disposition.kind === "accepted" || response.disposition.kind === "duplicate") {
|
|
83
|
+
markPlatformOutboundHandedOff(this.store.db, {
|
|
84
|
+
intentRef: item.intent.intent_ref,
|
|
85
|
+
deliveryRef: response.disposition.delivery_ref,
|
|
86
|
+
now: this.now,
|
|
87
|
+
});
|
|
88
|
+
const updated = readPlatformOutboundIntent(this.store.db, item.intent.intent_ref);
|
|
89
|
+
this.logger.info({
|
|
90
|
+
...fields,
|
|
91
|
+
stage: "platform_outbound_handoff",
|
|
92
|
+
disposition: response.disposition.kind,
|
|
93
|
+
delivery_ref: response.disposition.delivery_ref,
|
|
94
|
+
}, "Platform outbound intent handed off");
|
|
95
|
+
return { kind: "handed_off", intent: updated };
|
|
96
|
+
}
|
|
97
|
+
if (response.disposition.kind === "unavailable") {
|
|
98
|
+
return this.markFailure(item.intent, response.disposition.reason_code, true, fields);
|
|
99
|
+
}
|
|
100
|
+
return this.markFailure(item.intent, response.disposition.reason_code, false, fields);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const code = typeof error === "object" &&
|
|
104
|
+
error !== null &&
|
|
105
|
+
"code" in error &&
|
|
106
|
+
typeof error.code === "string"
|
|
107
|
+
? error.code.slice(0, 128)
|
|
108
|
+
: "platform_outbound_transport_failed";
|
|
109
|
+
const retryable = typeof error === "object" &&
|
|
110
|
+
error !== null &&
|
|
111
|
+
"retryable" in error &&
|
|
112
|
+
error.retryable === false
|
|
113
|
+
? false
|
|
114
|
+
: true;
|
|
115
|
+
return this.markFailure(item.intent, code, retryable, fields);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
markFailure(intent, errorCode, retryable, fields) {
|
|
119
|
+
const terminal = !retryable || intent.attempt_count >= this.maxAttempts;
|
|
120
|
+
if (terminal) {
|
|
121
|
+
markPlatformOutboundTerminal(this.store.db, {
|
|
122
|
+
intentRef: intent.intent_ref,
|
|
123
|
+
errorCode,
|
|
124
|
+
now: this.now,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const delay = Math.min(1_000 * 2 ** Math.max(0, intent.attempt_count - 1), MAX_BACKOFF_MS);
|
|
129
|
+
markPlatformOutboundRetry(this.store.db, {
|
|
130
|
+
intentRef: intent.intent_ref,
|
|
131
|
+
errorCode,
|
|
132
|
+
nextAttemptAt: new Date(this.now().getTime() + delay).toISOString(),
|
|
133
|
+
now: this.now,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const updated = readPlatformOutboundIntent(this.store.db, intent.intent_ref);
|
|
137
|
+
const disposition = terminal ? "terminal" : "retry_wait";
|
|
138
|
+
this.logger[terminal ? "error" : "warn"]({
|
|
139
|
+
...fields,
|
|
140
|
+
stage: "platform_outbound_handoff",
|
|
141
|
+
disposition,
|
|
142
|
+
error_code: errorCode,
|
|
143
|
+
}, terminal
|
|
144
|
+
? "Platform outbound intent failed terminally"
|
|
145
|
+
: "Platform outbound intent will retry");
|
|
146
|
+
return { kind: disposition, intent: updated, error_code: errorCode };
|
|
147
|
+
}
|
|
148
|
+
schedule(delay) {
|
|
149
|
+
if (this.stopped) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.timer = setTimeout(() => {
|
|
153
|
+
this.timer = undefined;
|
|
154
|
+
this.current = this.runOnce().finally(() => {
|
|
155
|
+
this.current = undefined;
|
|
156
|
+
const nextDelay = this.wakeRequested ? 0 : this.pollIntervalMs;
|
|
157
|
+
this.wakeRequested = false;
|
|
158
|
+
this.schedule(nextDelay);
|
|
159
|
+
});
|
|
160
|
+
}, delay);
|
|
161
|
+
this.timer.unref?.();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=worker.js.map
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
4
4
|
import { formatCliError, formatCliErrorReason, LaunchError } from "./errors.js";
|
|
5
|
+
import { recordInternalHostStartupFailure } from "./managed-host.js";
|
|
5
6
|
import { parseCliArgs, shouldEnsureDesktopIntegrationAutomatically } from "./args.js";
|
|
6
7
|
import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
|
|
7
8
|
import { resolveExistingProjectContext } from "./project-resolver.js";
|
|
@@ -362,8 +363,10 @@ async function runShutdownCommand(yes) {
|
|
|
362
363
|
"Tutti is ready to uninstall.",
|
|
363
364
|
].join("\n");
|
|
364
365
|
}
|
|
366
|
+
let activeCommand;
|
|
365
367
|
try {
|
|
366
368
|
const command = parseCliArgs(process.argv.slice(2));
|
|
369
|
+
activeCommand = command;
|
|
367
370
|
if (shouldEnsureDesktopIntegrationAutomatically(command)) {
|
|
368
371
|
ensureDesktopIntegrationForConsole();
|
|
369
372
|
}
|
|
@@ -451,6 +454,12 @@ try {
|
|
|
451
454
|
}
|
|
452
455
|
}
|
|
453
456
|
catch (error) {
|
|
457
|
+
if (activeCommand?.kind === "internal-host-run") {
|
|
458
|
+
recordInternalHostStartupFailure({
|
|
459
|
+
tuttiHome: resolveTuttiHome(process.env.TUTTI_HOME, process.cwd()),
|
|
460
|
+
error,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
454
463
|
console.error(formatCliError(error));
|
|
455
464
|
process.exitCode = 1;
|
|
456
465
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "shutdown_confirmation_required" | "shutdown_rejected" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable" | "desktop_integration_unavailable" | "desktop_protocol_invalid" | "desktop_open_failed";
|
|
1
|
+
export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "project_surface_conflict" | "project_surface_mismatch" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "shutdown_confirmation_required" | "shutdown_rejected" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable" | "desktop_integration_unavailable" | "desktop_protocol_invalid" | "desktop_open_failed";
|
|
2
2
|
export declare class LaunchError extends Error {
|
|
3
3
|
readonly code: LaunchErrorCode;
|
|
4
4
|
readonly next: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProjectId } from "@tutti/shared/ids";
|
|
2
|
+
import { type ProjectSurfaceProfile } from "@tutti/shared/domain";
|
|
2
3
|
import { TUTTI_MAINLINE_BRANCH } from "./git-bootstrap.js";
|
|
3
4
|
import { type FetchLike, type HostRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
|
|
4
5
|
import { type HostServerFactory, type HostServerHandle } from "./host-server-runtime.js";
|
|
@@ -12,6 +13,7 @@ export type { HostServerHandle } from "./host-server-runtime.js";
|
|
|
12
13
|
export type HostRuntimeStatus = "created" | "connected" | "recovered" | "taken_over";
|
|
13
14
|
export type HostRuntimeSummary = {
|
|
14
15
|
status: HostRuntimeStatus;
|
|
16
|
+
surface_profile: ProjectSurfaceProfile;
|
|
15
17
|
workspace_root: string;
|
|
16
18
|
project_id: ProjectId;
|
|
17
19
|
branch: typeof TUTTI_MAINLINE_BRANCH;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { createProjectSurfaceProfile } from "@tutti/shared/domain";
|
|
2
3
|
import { connectRelayHostTunnel, RelayHostControlClientError, refreshRelayJoinToken as refreshRelayJoinTokenWithRelay, registerRelayHostConnection as registerRelayHostConnectionWithRelay, } from "@tutti/relay-client";
|
|
3
4
|
import { createHostServer } from "../http/create-server.js";
|
|
4
5
|
import { createHostProjectApiTunnelRequestHandler, createHostProjectApiTunnelStreamRequestHandler, } from "../http/host-tunnel.js";
|
|
@@ -56,6 +57,7 @@ async function requireTakeoverConfirmation(options) {
|
|
|
56
57
|
function createSummary(options) {
|
|
57
58
|
const summary = {
|
|
58
59
|
status: options.status,
|
|
60
|
+
surface_profile: options.surfaceProfile,
|
|
59
61
|
workspace_root: options.workspaceRoot,
|
|
60
62
|
project_id: options.projectId,
|
|
61
63
|
branch: TUTTI_MAINLINE_BRANCH,
|
|
@@ -129,6 +131,12 @@ export async function startLaunchProject(options) {
|
|
|
129
131
|
let allowWorkspaceRootTakeover = false;
|
|
130
132
|
if (context.existing_identity.kind === "present") {
|
|
131
133
|
const projectId = context.existing_identity.project_id;
|
|
134
|
+
const existingBinding = readMachineProjectBinding(context.tutti_home, projectId);
|
|
135
|
+
const existingSurfaceProfile = existingBinding?.surface_profile ?? createProjectSurfaceProfile();
|
|
136
|
+
if (options.projectSurface !== undefined &&
|
|
137
|
+
existingSurfaceProfile.kind !== options.projectSurface) {
|
|
138
|
+
throw new LaunchError("project_surface_conflict", `This project is already configured for the ${existingSurfaceProfile.kind} collaboration surface`, "Use the existing project surface. Surface migration requires a separate explicit migration flow.");
|
|
139
|
+
}
|
|
132
140
|
await reconcileReplacedWorkspaceHosts({
|
|
133
141
|
tuttiHome: context.tutti_home,
|
|
134
142
|
workspaceRoot: context.workspace_root,
|
|
@@ -181,6 +189,7 @@ export async function startLaunchProject(options) {
|
|
|
181
189
|
kind: "connected",
|
|
182
190
|
summary: createSummary({
|
|
183
191
|
status: "connected",
|
|
192
|
+
surfaceProfile: existingSurfaceProfile,
|
|
184
193
|
workspaceRoot: context.workspace_root,
|
|
185
194
|
projectId,
|
|
186
195
|
relayUrl: context.relay_url,
|
|
@@ -260,6 +269,9 @@ export async function startLaunchProject(options) {
|
|
|
260
269
|
};
|
|
261
270
|
const getLaunchStatus = () => hostLocalLaunchStatusFromConnectionManagerState(preparation.project_id, readConnectionState());
|
|
262
271
|
const refreshRelayJoinUrl = async () => {
|
|
272
|
+
if (preparation.machine_local.binding.surface_profile.kind !== "standalone") {
|
|
273
|
+
return relayStateFromConnectionManagerState(preparation.relay_url, readConnectionState());
|
|
274
|
+
}
|
|
263
275
|
if (relayRuntime.connectionManager === undefined) {
|
|
264
276
|
return relayStateFromConnectionManagerState(preparation.relay_url, readConnectionState());
|
|
265
277
|
}
|
|
@@ -296,10 +308,13 @@ export async function startLaunchProject(options) {
|
|
|
296
308
|
heartbeatIntervalMs: registered.heartbeatIntervalMs,
|
|
297
309
|
handleRequest: createHostProjectApiTunnelRequestHandler({
|
|
298
310
|
app: host.app,
|
|
311
|
+
projectSurface: preparation.machine_local.binding.surface_profile.kind,
|
|
299
312
|
trustedMetadataStore: host.trusted_relay_metadata_store,
|
|
313
|
+
integrationDelivery: host.integration_delivery,
|
|
300
314
|
}),
|
|
301
315
|
handleStreamRequest: createHostProjectApiTunnelStreamRequestHandler({
|
|
302
316
|
baseUrl: host.runtime_endpoint.base_url,
|
|
317
|
+
projectSurface: preparation.machine_local.binding.surface_profile.kind,
|
|
303
318
|
trustedMetadataStore: host.trusted_relay_metadata_store,
|
|
304
319
|
}),
|
|
305
320
|
}),
|
|
@@ -354,6 +369,7 @@ export async function startLaunchProject(options) {
|
|
|
354
369
|
host,
|
|
355
370
|
summary: createSummary({
|
|
356
371
|
status: startupStatus,
|
|
372
|
+
surfaceProfile: preparation.machine_local.binding.surface_profile,
|
|
357
373
|
workspaceRoot: preparation.workspace_root,
|
|
358
374
|
projectId: preparation.project_id,
|
|
359
375
|
relayUrl: preparation.relay_url,
|
|
@@ -374,7 +390,24 @@ export function formatLaunchLifecycleResult(result, options = {}) {
|
|
|
374
390
|
if (summary.setup_url !== undefined) {
|
|
375
391
|
fields.push(urlLine("Setup URL", summary.setup_url, options));
|
|
376
392
|
}
|
|
377
|
-
if (summary.
|
|
393
|
+
if (summary.surface_profile.kind === "feishu") {
|
|
394
|
+
fields.push("Collaboration surface: Feishu");
|
|
395
|
+
if (summary.relay?.feishu_setup?.state === "pairing_required" &&
|
|
396
|
+
summary.relay.feishu_setup.visibility === "visible_once") {
|
|
397
|
+
fields.push(`Connect command: ${summary.relay.feishu_setup.command}`);
|
|
398
|
+
fields.push(`Connect code expires at: ${summary.relay.feishu_setup.expires_at}`);
|
|
399
|
+
}
|
|
400
|
+
else if (summary.relay?.feishu_setup?.state === "pairing_required") {
|
|
401
|
+
fields.push("Feishu conversation: pairing code is not recoverable; restart from Local Console to rotate it");
|
|
402
|
+
}
|
|
403
|
+
else if (summary.relay?.feishu_setup?.state === "connected") {
|
|
404
|
+
fields.push("Feishu conversation: connected");
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
fields.push("Feishu Owner: open this project through Local Console to connect an account");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else if (summary.relay?.join_url !== undefined) {
|
|
378
411
|
fields.push(urlLine("Join URL", summary.relay.join_url, options));
|
|
379
412
|
fields.push(formatJoinLinkValidity({
|
|
380
413
|
...(summary.relay.join_token_expires_at === undefined
|
|
@@ -12,17 +12,28 @@ class WorkspaceUnavailableError extends Error {
|
|
|
12
12
|
const DEFAULT_RETRY_DELAYS_MS = [0, 1_000, 2_000, 5_000, 10_000, 30_000];
|
|
13
13
|
const DEFAULT_JITTER_RATIO = 0.2;
|
|
14
14
|
const DEFAULT_WORKSPACE_CHECK_INTERVAL_MS = 30_000;
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
previous
|
|
19
|
-
|
|
15
|
+
function mergeKnownVisibleCapabilities(previous, next) {
|
|
16
|
+
let merged = next;
|
|
17
|
+
if (next.join_url === undefined &&
|
|
18
|
+
previous?.join_url !== undefined &&
|
|
19
|
+
previous.join_token_expires_at === next.join_token_expires_at) {
|
|
20
|
+
merged = {
|
|
21
|
+
...merged,
|
|
22
|
+
join_url: previous.join_url,
|
|
23
|
+
join_url_visibility: previous.join_url_visibility,
|
|
24
|
+
};
|
|
20
25
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
const previousSetup = previous?.feishu_setup;
|
|
27
|
+
const nextSetup = next.feishu_setup;
|
|
28
|
+
if (previousSetup?.state === "pairing_required" &&
|
|
29
|
+
previousSetup.visibility === "visible_once" &&
|
|
30
|
+
nextSetup?.state === "pairing_required" &&
|
|
31
|
+
nextSetup.visibility === "not_recoverable" &&
|
|
32
|
+
nextSetup.expires_at !== undefined &&
|
|
33
|
+
previousSetup.expires_at === nextSetup.expires_at) {
|
|
34
|
+
merged = { ...merged, feishu_setup: previousSetup };
|
|
35
|
+
}
|
|
36
|
+
return merged;
|
|
26
37
|
}
|
|
27
38
|
function retryableConnectionError(error) {
|
|
28
39
|
if (error instanceof RelayHostControlClientError) {
|
|
@@ -249,7 +260,7 @@ class DefaultHostRelayConnectionManager {
|
|
|
249
260
|
tunnel.close(1000, "host stopping");
|
|
250
261
|
throw new Error("Relay connection attempt was cancelled");
|
|
251
262
|
}
|
|
252
|
-
const connection =
|
|
263
|
+
const connection = mergeKnownVisibleCapabilities(this.lastConnection, registered.summary);
|
|
253
264
|
const previousTunnel = this.activeTunnel;
|
|
254
265
|
this.pendingTunnel = undefined;
|
|
255
266
|
this.activeTunnel = tunnel;
|
|
@@ -97,6 +97,7 @@ export function relayStatusFromLocalStatus(status) {
|
|
|
97
97
|
join_token_reusable: status.relay.join_token_reusable ?? true,
|
|
98
98
|
tunnel_url: status.relay.tunnel_url,
|
|
99
99
|
connected_at: status.relay.connected_at,
|
|
100
|
+
...(status.relay.feishu_setup === undefined ? {} : { feishu_setup: status.relay.feishu_setup }),
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
//# sourceMappingURL=host-relay-status.js.map
|