vellum 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
- package/src/__tests__/app-bundler.test.ts +12 -33
- package/src/__tests__/browser-skill-endstate.test.ts +1 -5
- package/src/__tests__/call-orchestrator.test.ts +328 -0
- package/src/__tests__/call-state.test.ts +133 -0
- package/src/__tests__/call-store.test.ts +476 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
- package/src/__tests__/config-schema.test.ts +49 -0
- package/src/__tests__/doordash-session.test.ts +9 -0
- package/src/__tests__/ipc-snapshot.test.ts +34 -0
- package/src/__tests__/registry.test.ts +13 -8
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
- package/src/__tests__/run-orchestrator.test.ts +3 -3
- package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
- package/src/__tests__/runtime-runs-http.test.ts +1 -19
- package/src/__tests__/runtime-runs.test.ts +7 -7
- package/src/__tests__/session-queue.test.ts +50 -0
- package/src/__tests__/turn-commit.test.ts +56 -0
- package/src/__tests__/workspace-git-service.test.ts +217 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
- package/src/bundler/app-bundler.ts +29 -12
- package/src/calls/call-constants.ts +10 -0
- package/src/calls/call-orchestrator.ts +364 -0
- package/src/calls/call-state.ts +64 -0
- package/src/calls/call-store.ts +229 -0
- package/src/calls/relay-server.ts +298 -0
- package/src/calls/twilio-config.ts +34 -0
- package/src/calls/twilio-provider.ts +169 -0
- package/src/calls/twilio-routes.ts +236 -0
- package/src/calls/types.ts +37 -0
- package/src/calls/voice-provider.ts +14 -0
- package/src/cli/doordash.ts +5 -24
- package/src/config/bundled-skills/doordash/SKILL.md +104 -0
- package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
- package/src/config/defaults.ts +11 -0
- package/src/config/schema.ts +57 -0
- package/src/config/system-prompt.ts +50 -1
- package/src/config/types.ts +1 -0
- package/src/daemon/handlers/config.ts +30 -0
- package/src/daemon/handlers/index.ts +6 -0
- package/src/daemon/handlers/work-items.ts +142 -2
- package/src/daemon/ipc-contract-inventory.json +12 -0
- package/src/daemon/ipc-contract.ts +52 -0
- package/src/daemon/lifecycle.ts +27 -5
- package/src/daemon/server.ts +10 -12
- package/src/daemon/session-tool-setup.ts +6 -0
- package/src/daemon/session.ts +40 -1
- package/src/index.ts +2 -0
- package/src/media/gemini-image-service.ts +1 -1
- package/src/memory/db.ts +266 -0
- package/src/memory/schema.ts +42 -0
- package/src/runtime/http-server.ts +189 -25
- package/src/runtime/http-types.ts +0 -2
- package/src/runtime/routes/attachment-routes.ts +6 -6
- package/src/runtime/routes/channel-routes.ts +16 -18
- package/src/runtime/routes/conversation-routes.ts +5 -9
- package/src/runtime/routes/run-routes.ts +4 -8
- package/src/runtime/run-orchestrator.ts +32 -5
- package/src/tools/calls/call-end.ts +117 -0
- package/src/tools/calls/call-start.ts +134 -0
- package/src/tools/calls/call-status.ts +97 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/registry.ts +2 -4
- package/src/tools/tasks/index.ts +2 -0
- package/src/tools/tasks/task-delete.ts +49 -8
- package/src/tools/tasks/task-run.ts +9 -1
- package/src/tools/tasks/work-item-enqueue.ts +93 -3
- package/src/tools/tasks/work-item-list.ts +10 -25
- package/src/tools/tasks/work-item-remove.ts +112 -0
- package/src/tools/tasks/work-item-update.ts +186 -0
- package/src/tools/tool-manifest.ts +39 -31
- package/src/tools/ui-surface/definitions.ts +3 -0
- package/src/work-items/work-item-store.ts +209 -0
- package/src/workspace/commit-message-enrichment-service.ts +260 -0
- package/src/workspace/commit-message-provider.ts +95 -0
- package/src/workspace/git-service.ts +187 -32
- package/src/workspace/heartbeat-service.ts +70 -13
- package/src/workspace/turn-commit.ts +39 -49
|
@@ -7,6 +7,9 @@ import {
|
|
|
7
7
|
WorkspaceGitService,
|
|
8
8
|
getWorkspaceGitService,
|
|
9
9
|
_resetGitServiceRegistry,
|
|
10
|
+
_resetBreaker,
|
|
11
|
+
_getConsecutiveFailures,
|
|
12
|
+
isDeadlineExpired,
|
|
10
13
|
} from '../workspace/git-service.js';
|
|
11
14
|
|
|
12
15
|
describe('WorkspaceGitService', () => {
|
|
@@ -747,4 +750,218 @@ describe('WorkspaceGitService', () => {
|
|
|
747
750
|
expect(status.untracked).toContain('README.md');
|
|
748
751
|
});
|
|
749
752
|
});
|
|
753
|
+
|
|
754
|
+
describe('deadline-aware commitIfDirty', () => {
|
|
755
|
+
test('deadline expired before lock acquisition skips commit quickly', async () => {
|
|
756
|
+
const service = new WorkspaceGitService(testDir);
|
|
757
|
+
await service.ensureInitialized();
|
|
758
|
+
|
|
759
|
+
// Create a file so the workspace is dirty
|
|
760
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
761
|
+
|
|
762
|
+
// Use a deadline that has already passed
|
|
763
|
+
const pastDeadline = Date.now() - 1000;
|
|
764
|
+
const result = await service.commitIfDirty(
|
|
765
|
+
() => ({ message: 'should not commit' }),
|
|
766
|
+
{ deadlineMs: pastDeadline },
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
expect(result.committed).toBe(false);
|
|
770
|
+
|
|
771
|
+
// File should still be uncommitted
|
|
772
|
+
const status = await service.getStatus();
|
|
773
|
+
expect(status.clean).toBe(false);
|
|
774
|
+
expect(status.untracked).toContain('test.txt');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
test('deadline far in the future allows commit to proceed', async () => {
|
|
778
|
+
const service = new WorkspaceGitService(testDir);
|
|
779
|
+
await service.ensureInitialized();
|
|
780
|
+
|
|
781
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
782
|
+
|
|
783
|
+
// Use a deadline far in the future
|
|
784
|
+
const futureDeadline = Date.now() + 60_000;
|
|
785
|
+
const result = await service.commitIfDirty(
|
|
786
|
+
() => ({ message: 'deadline commit' }),
|
|
787
|
+
{ deadlineMs: futureDeadline },
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
expect(result.committed).toBe(true);
|
|
791
|
+
|
|
792
|
+
// Verify the commit was actually created
|
|
793
|
+
const log = execFileSync('git', ['log', '--oneline', '-n', '1'], {
|
|
794
|
+
cwd: testDir,
|
|
795
|
+
encoding: 'utf-8',
|
|
796
|
+
});
|
|
797
|
+
expect(log).toContain('deadline commit');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test('no deadline option allows commit as normal', async () => {
|
|
801
|
+
const service = new WorkspaceGitService(testDir);
|
|
802
|
+
await service.ensureInitialized();
|
|
803
|
+
|
|
804
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
805
|
+
|
|
806
|
+
// No deadline option at all
|
|
807
|
+
const result = await service.commitIfDirty(
|
|
808
|
+
() => ({ message: 'no deadline commit' }),
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
expect(result.committed).toBe(true);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
describe('breaker re-check under lock', () => {
|
|
816
|
+
test('queued call that acquires lock after breaker opens skips commit', async () => {
|
|
817
|
+
const service = new WorkspaceGitService(testDir);
|
|
818
|
+
await service.ensureInitialized();
|
|
819
|
+
|
|
820
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
821
|
+
|
|
822
|
+
// Simulate a breaker that opened between the pre-lock check and lock acquisition.
|
|
823
|
+
// We do this by:
|
|
824
|
+
// 1. Starting a commitIfDirty call (which passes the pre-lock breaker check)
|
|
825
|
+
// 2. Forcing the breaker open while the call is in progress
|
|
826
|
+
|
|
827
|
+
// First, force the breaker open by setting internal state directly.
|
|
828
|
+
// Since commitIfDirty re-checks the breaker after acquiring the lock,
|
|
829
|
+
// a call that passes the pre-lock check but finds the breaker open
|
|
830
|
+
// after acquiring the lock should bail out.
|
|
831
|
+
const internal = service as unknown as {
|
|
832
|
+
consecutiveFailures: number;
|
|
833
|
+
nextAllowedAttemptMs: number;
|
|
834
|
+
};
|
|
835
|
+
internal.consecutiveFailures = 5;
|
|
836
|
+
internal.nextAllowedAttemptMs = Date.now() + 60_000; // far in the future
|
|
837
|
+
|
|
838
|
+
// With breaker open, commitIfDirty should skip (pre-lock check)
|
|
839
|
+
const result = await service.commitIfDirty(
|
|
840
|
+
() => ({ message: 'should not commit' }),
|
|
841
|
+
);
|
|
842
|
+
expect(result.committed).toBe(false);
|
|
843
|
+
|
|
844
|
+
// Reset breaker
|
|
845
|
+
_resetBreaker(service);
|
|
846
|
+
|
|
847
|
+
// Now the commit should proceed normally
|
|
848
|
+
const result2 = await service.commitIfDirty(
|
|
849
|
+
() => ({ message: 'after breaker reset' }),
|
|
850
|
+
);
|
|
851
|
+
expect(result2.committed).toBe(true);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
test('breaker early return inside lock does not reset breaker via recordSuccess', async () => {
|
|
855
|
+
const service = new WorkspaceGitService(testDir);
|
|
856
|
+
await service.ensureInitialized();
|
|
857
|
+
|
|
858
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
859
|
+
|
|
860
|
+
// Force breaker open with known failure count
|
|
861
|
+
const internal = service as unknown as {
|
|
862
|
+
consecutiveFailures: number;
|
|
863
|
+
nextAllowedAttemptMs: number;
|
|
864
|
+
};
|
|
865
|
+
internal.consecutiveFailures = 5;
|
|
866
|
+
internal.nextAllowedAttemptMs = Date.now() + 60_000;
|
|
867
|
+
|
|
868
|
+
// Pre-lock check catches the open breaker and returns early.
|
|
869
|
+
// Verify that consecutiveFailures is NOT reset to 0.
|
|
870
|
+
const result = await service.commitIfDirty(
|
|
871
|
+
() => ({ message: 'should not commit' }),
|
|
872
|
+
);
|
|
873
|
+
expect(result.committed).toBe(false);
|
|
874
|
+
expect(_getConsecutiveFailures(service)).toBe(5);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test('deadline early return inside lock does not reset breaker via recordSuccess', async () => {
|
|
878
|
+
const service = new WorkspaceGitService(testDir);
|
|
879
|
+
await service.ensureInitialized();
|
|
880
|
+
|
|
881
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
882
|
+
|
|
883
|
+
// Set up prior failures (breaker closed but failures recorded)
|
|
884
|
+
const internal = service as unknown as {
|
|
885
|
+
consecutiveFailures: number;
|
|
886
|
+
nextAllowedAttemptMs: number;
|
|
887
|
+
};
|
|
888
|
+
internal.consecutiveFailures = 3;
|
|
889
|
+
// Set nextAllowedAttemptMs in the past so the breaker check passes
|
|
890
|
+
// but consecutiveFailures is non-zero
|
|
891
|
+
internal.nextAllowedAttemptMs = Date.now() - 1000;
|
|
892
|
+
|
|
893
|
+
// Use a deadline that has already passed — this triggers the pre-lock
|
|
894
|
+
// deadline fast-path. consecutiveFailures should NOT be reset.
|
|
895
|
+
const result = await service.commitIfDirty(
|
|
896
|
+
() => ({ message: 'should not commit' }),
|
|
897
|
+
{ deadlineMs: Date.now() - 1000 },
|
|
898
|
+
);
|
|
899
|
+
expect(result.committed).toBe(false);
|
|
900
|
+
expect(_getConsecutiveFailures(service)).toBe(3);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test('successful git operation after failures resets breaker', async () => {
|
|
904
|
+
const service = new WorkspaceGitService(testDir);
|
|
905
|
+
await service.ensureInitialized();
|
|
906
|
+
|
|
907
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
908
|
+
|
|
909
|
+
// Set up prior failures with breaker closed (backoff expired)
|
|
910
|
+
const internal = service as unknown as {
|
|
911
|
+
consecutiveFailures: number;
|
|
912
|
+
nextAllowedAttemptMs: number;
|
|
913
|
+
};
|
|
914
|
+
internal.consecutiveFailures = 3;
|
|
915
|
+
internal.nextAllowedAttemptMs = Date.now() - 1000;
|
|
916
|
+
|
|
917
|
+
// Commit should succeed and reset the breaker
|
|
918
|
+
const result = await service.commitIfDirty(
|
|
919
|
+
() => ({ message: 'recovery commit' }),
|
|
920
|
+
);
|
|
921
|
+
expect(result.committed).toBe(true);
|
|
922
|
+
expect(_getConsecutiveFailures(service)).toBe(0);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test('bypassBreaker ignores breaker state', async () => {
|
|
926
|
+
const service = new WorkspaceGitService(testDir);
|
|
927
|
+
await service.ensureInitialized();
|
|
928
|
+
|
|
929
|
+
writeFileSync(join(testDir, 'test.txt'), 'content');
|
|
930
|
+
|
|
931
|
+
// Force breaker open
|
|
932
|
+
const internal = service as unknown as {
|
|
933
|
+
consecutiveFailures: number;
|
|
934
|
+
nextAllowedAttemptMs: number;
|
|
935
|
+
};
|
|
936
|
+
internal.consecutiveFailures = 5;
|
|
937
|
+
internal.nextAllowedAttemptMs = Date.now() + 60_000;
|
|
938
|
+
|
|
939
|
+
// With bypassBreaker, commit should succeed despite open breaker
|
|
940
|
+
const result = await service.commitIfDirty(
|
|
941
|
+
() => ({ message: 'bypass breaker commit' }),
|
|
942
|
+
{ bypassBreaker: true },
|
|
943
|
+
);
|
|
944
|
+
expect(result.committed).toBe(true);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe('isDeadlineExpired helper', () => {
|
|
949
|
+
test('returns false when deadlineMs is undefined', () => {
|
|
950
|
+
expect(isDeadlineExpired(undefined)).toBe(false);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test('returns false when deadline is in the future', () => {
|
|
954
|
+
expect(isDeadlineExpired(Date.now() + 60_000)).toBe(false);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test('returns true when deadline is in the past', () => {
|
|
958
|
+
expect(isDeadlineExpired(Date.now() - 1000)).toBe(true);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test('returns true when deadline equals current time', () => {
|
|
962
|
+
const now = Date.now();
|
|
963
|
+
// Use a deadline slightly in the past to avoid timing flakes
|
|
964
|
+
expect(isDeadlineExpired(now - 1)).toBe(true);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
750
967
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
HeartbeatService,
|
|
12
12
|
_resetHeartbeatState,
|
|
13
13
|
} from '../workspace/heartbeat-service.js';
|
|
14
|
+
import type { CommitMessageProvider, CommitContext, CommitMessageResult } from '../workspace/commit-message-provider.js';
|
|
14
15
|
|
|
15
16
|
describe('HeartbeatService', () => {
|
|
16
17
|
let testDir: string;
|
|
@@ -344,4 +345,132 @@ describe('HeartbeatService', () => {
|
|
|
344
345
|
await heartbeat.stop(); // Idempotent
|
|
345
346
|
});
|
|
346
347
|
});
|
|
348
|
+
|
|
349
|
+
describe('custom commit message provider', () => {
|
|
350
|
+
test('heartbeat commit uses custom provider message', async () => {
|
|
351
|
+
writeFileSync(join(testDir, 'file.txt'), 'content');
|
|
352
|
+
|
|
353
|
+
const customProvider: CommitMessageProvider = {
|
|
354
|
+
buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
|
|
355
|
+
return {
|
|
356
|
+
message: `CUSTOM-HEARTBEAT: ${ctx.changedFiles.length} files via ${ctx.trigger}`,
|
|
357
|
+
metadata: { customProvider: true, trigger: ctx.trigger },
|
|
358
|
+
};
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
let currentTime = 1000000;
|
|
363
|
+
const heartbeat = new HeartbeatService({
|
|
364
|
+
ageThresholdMs: 5 * 60 * 1000,
|
|
365
|
+
fileThreshold: 100,
|
|
366
|
+
getServices: () => services,
|
|
367
|
+
now: () => currentTime,
|
|
368
|
+
commitMessageProvider: customProvider,
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// First check registers dirty state
|
|
372
|
+
await heartbeat.check();
|
|
373
|
+
// Advance time past threshold
|
|
374
|
+
currentTime += 6 * 60 * 1000;
|
|
375
|
+
// Second check commits
|
|
376
|
+
const result = await heartbeat.check();
|
|
377
|
+
expect(result.committed).toBe(1);
|
|
378
|
+
|
|
379
|
+
const commitMsg = execFileSync('git', ['log', '-1', '--pretty=%B'], {
|
|
380
|
+
cwd: testDir,
|
|
381
|
+
encoding: 'utf-8',
|
|
382
|
+
});
|
|
383
|
+
expect(commitMsg).toContain('CUSTOM-HEARTBEAT:');
|
|
384
|
+
expect(commitMsg).toContain('via heartbeat');
|
|
385
|
+
expect(commitMsg).toContain('customProvider: true');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('shutdown commit uses custom provider message', async () => {
|
|
389
|
+
writeFileSync(join(testDir, 'unsaved.txt'), 'uncommitted content');
|
|
390
|
+
|
|
391
|
+
const customProvider: CommitMessageProvider = {
|
|
392
|
+
buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
|
|
393
|
+
return {
|
|
394
|
+
message: `CUSTOM-SHUTDOWN: saving ${ctx.changedFiles.length} files`,
|
|
395
|
+
metadata: { shutdownProvider: true },
|
|
396
|
+
};
|
|
397
|
+
},
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const heartbeat = new HeartbeatService({
|
|
401
|
+
getServices: () => services,
|
|
402
|
+
commitMessageProvider: customProvider,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result = await heartbeat.commitAllPending();
|
|
406
|
+
expect(result.committed).toBe(1);
|
|
407
|
+
|
|
408
|
+
const commitMsg = execFileSync('git', ['log', '-1', '--pretty=%B'], {
|
|
409
|
+
cwd: testDir,
|
|
410
|
+
encoding: 'utf-8',
|
|
411
|
+
});
|
|
412
|
+
expect(commitMsg).toContain('CUSTOM-SHUTDOWN: saving');
|
|
413
|
+
expect(commitMsg).toContain('shutdownProvider: true');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('custom provider receives correct context fields for heartbeat trigger', async () => {
|
|
417
|
+
writeFileSync(join(testDir, 'a.txt'), 'a');
|
|
418
|
+
writeFileSync(join(testDir, 'b.txt'), 'b');
|
|
419
|
+
|
|
420
|
+
let capturedCtx: CommitContext | null = null;
|
|
421
|
+
const customProvider: CommitMessageProvider = {
|
|
422
|
+
buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
|
|
423
|
+
capturedCtx = ctx;
|
|
424
|
+
return { message: 'capture-context' };
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
let currentTime = 1000000;
|
|
429
|
+
const heartbeat = new HeartbeatService({
|
|
430
|
+
ageThresholdMs: 5 * 60 * 1000,
|
|
431
|
+
fileThreshold: 100,
|
|
432
|
+
getServices: () => services,
|
|
433
|
+
now: () => currentTime,
|
|
434
|
+
commitMessageProvider: customProvider,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// Register dirty state
|
|
438
|
+
await heartbeat.check();
|
|
439
|
+
// Advance past threshold
|
|
440
|
+
currentTime += 6 * 60 * 1000;
|
|
441
|
+
await heartbeat.check();
|
|
442
|
+
|
|
443
|
+
expect(capturedCtx).not.toBeNull();
|
|
444
|
+
expect(capturedCtx!.trigger).toBe('heartbeat');
|
|
445
|
+
expect(capturedCtx!.workspaceDir).toBe(testDir);
|
|
446
|
+
expect(capturedCtx!.changedFiles).toContain('a.txt');
|
|
447
|
+
expect(capturedCtx!.changedFiles).toContain('b.txt');
|
|
448
|
+
expect(capturedCtx!.timestampMs).toBe(currentTime);
|
|
449
|
+
expect(capturedCtx!.reason).toBeDefined();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('custom provider receives correct context fields for shutdown trigger', async () => {
|
|
453
|
+
writeFileSync(join(testDir, 'shutdown-file.txt'), 'data');
|
|
454
|
+
|
|
455
|
+
let capturedCtx: CommitContext | null = null;
|
|
456
|
+
const customProvider: CommitMessageProvider = {
|
|
457
|
+
buildImmediateMessage(ctx: CommitContext): CommitMessageResult {
|
|
458
|
+
capturedCtx = ctx;
|
|
459
|
+
return { message: 'capture-shutdown-context' };
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const heartbeat = new HeartbeatService({
|
|
464
|
+
getServices: () => services,
|
|
465
|
+
commitMessageProvider: customProvider,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await heartbeat.commitAllPending();
|
|
469
|
+
|
|
470
|
+
expect(capturedCtx).not.toBeNull();
|
|
471
|
+
expect(capturedCtx!.trigger).toBe('shutdown');
|
|
472
|
+
expect(capturedCtx!.workspaceDir).toBe(testDir);
|
|
473
|
+
expect(capturedCtx!.changedFiles).toContain('shutdown-file.txt');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
347
476
|
});
|
|
@@ -35,15 +35,25 @@ interface FetchedAsset {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Extract all remote (http/https) URLs from HTML content.
|
|
38
|
-
* Looks in src=, href
|
|
38
|
+
* Looks in src=, href= on asset elements (not <a> tags), and CSS url() references.
|
|
39
39
|
*/
|
|
40
40
|
export function extractRemoteUrls(html: string): string[] {
|
|
41
41
|
const urls = new Set<string>();
|
|
42
42
|
|
|
43
|
-
// Match src="..."
|
|
44
|
-
const
|
|
43
|
+
// Match src="..." attributes on any element
|
|
44
|
+
const srcRe = /\bsrc\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))/gi;
|
|
45
45
|
let m: RegExpExecArray | null;
|
|
46
|
-
while ((m =
|
|
46
|
+
while ((m = srcRe.exec(html)) !== null) {
|
|
47
|
+
const url = m[1] ?? m[2] ?? m[3];
|
|
48
|
+
if (url && /^https?:\/\//i.test(url)) {
|
|
49
|
+
urls.add(url);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Match href="..." only on <link> elements (stylesheets, icons, preloads), not <a> tags.
|
|
54
|
+
// Captures <link ...href="..."> where href appears anywhere within the tag.
|
|
55
|
+
const linkRe = /<link\b[^>]*?\bhref\s*=\s*(?:"([^"]*?)"|'([^']*?)'|([^\s>]+))[^>]*?\/?>/gi;
|
|
56
|
+
while ((m = linkRe.exec(html)) !== null) {
|
|
47
57
|
const url = m[1] ?? m[2] ?? m[3];
|
|
48
58
|
if (url && /^https?:\/\//i.test(url)) {
|
|
49
59
|
urls.add(url);
|
|
@@ -104,13 +114,17 @@ export async function materializeAssets(
|
|
|
104
114
|
try {
|
|
105
115
|
const controller = new AbortController();
|
|
106
116
|
const timeout = setTimeout(() => controller.abort(), ASSET_FETCH_TIMEOUT_MS);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
let buf: Buffer;
|
|
118
|
+
try {
|
|
119
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
120
|
+
if (!resp.ok) {
|
|
121
|
+
bundlerLog.warn({ url, status: resp.status }, 'Failed to fetch asset, keeping original URL');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
buf = Buffer.from(await resp.arrayBuffer());
|
|
125
|
+
} finally {
|
|
126
|
+
clearTimeout(timeout);
|
|
112
127
|
}
|
|
113
|
-
const buf = Buffer.from(await resp.arrayBuffer());
|
|
114
128
|
const filename = assetFilename(url);
|
|
115
129
|
const archivePath = `assets/${filename}`;
|
|
116
130
|
assets.push({ archivePath, data: buf });
|
|
@@ -121,9 +135,12 @@ export async function materializeAssets(
|
|
|
121
135
|
}),
|
|
122
136
|
);
|
|
123
137
|
|
|
124
|
-
// Rewrite URLs in HTML — replace each occurrence of the original URL with the local path
|
|
138
|
+
// Rewrite URLs in HTML — replace each occurrence of the original URL with the local path.
|
|
139
|
+
// Sort by length descending so longer URLs are replaced first, preventing prefix collisions
|
|
140
|
+
// (e.g. "https://cdn/x" replacing part of "https://cdn/x/y.png").
|
|
141
|
+
const sortedEntries = [...urlMap.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
125
142
|
let rewrittenHtml = html;
|
|
126
|
-
for (const [originalUrl, localPath] of
|
|
143
|
+
for (const [originalUrl, localPath] of sortedEntries) {
|
|
127
144
|
// Escape regex special chars in the URL
|
|
128
145
|
const escaped = originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
129
146
|
rewrittenHtml = rewrittenHtml.replace(new RegExp(escaped, 'g'), localPath);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Emergency/high-risk numbers that should never be called
|
|
2
|
+
export const DENIED_NUMBERS = new Set([
|
|
3
|
+
'911', '112', '999', '000', '110', '119', // Emergency
|
|
4
|
+
'+1911', '+1112',
|
|
5
|
+
]);
|
|
6
|
+
|
|
7
|
+
// Call limits
|
|
8
|
+
export const MAX_CALL_DURATION_MS = 12 * 60 * 1000; // 12 minutes
|
|
9
|
+
export const USER_CONSULTATION_TIMEOUT_MS = 90 * 1000; // 90 seconds
|
|
10
|
+
export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds
|